积分与余额账务系统硬切重构
This commit is contained in:
@@ -25,6 +25,9 @@
|
||||
!/tests/widget-system/auth-source-contract.test.cjs
|
||||
!/tests/widget-system/user-badges-contract.test.cjs
|
||||
!/tests/widget-system/user-features-contract.test.cjs
|
||||
!/tests/widget-system/asset-ledger-contract.test.cjs
|
||||
!/tests/asset-ledger/
|
||||
!/tests/asset-ledger/*.php
|
||||
!/tests/visual/docker-compose.zibll.yml
|
||||
!/tests/visual/zibll-Caddyfile
|
||||
!/tests/visual/README.md
|
||||
@@ -48,6 +51,7 @@ assets/admin/.vite-temp/
|
||||
|
||||
# Temporary / recovery helpers
|
||||
restore-helper-monoliths.php
|
||||
/.phpunit.cache/
|
||||
|
||||
# OS / editor noise
|
||||
.DS_Store
|
||||
|
||||
@@ -12,6 +12,21 @@ import { apiMethods } from './modules/index.js'
|
||||
const API_ROOT = window.aksrtAdminData?.apiUrl || '/wp-json/aksrt/v1'
|
||||
const NONCE = window.aksrtAdminData?.nonce || ''
|
||||
|
||||
function createIdempotencyKey() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
if (typeof crypto === 'undefined' || typeof crypto.getRandomValues !== 'function') {
|
||||
throw new Error('当前浏览器无法生成安全操作标识')
|
||||
}
|
||||
const bytes = new Uint8Array(16)
|
||||
crypto.getRandomValues(bytes)
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
|
||||
function normalizeDomainKey(domain) {
|
||||
return String(domain || 'global').trim().replace(/-/g, '_')
|
||||
}
|
||||
@@ -85,6 +100,7 @@ function joinRestUrl(baseUrl, endpoint) {
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.baseUrl = API_ROOT
|
||||
this.pendingIdempotencyKeys = new Map()
|
||||
}
|
||||
|
||||
normalizeDomainKey(domain) {
|
||||
@@ -138,11 +154,22 @@ class ApiClient {
|
||||
finalBody = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const isWrite = method !== 'GET' && method !== 'HEAD'
|
||||
const suppliedKey = headers?.['Idempotency-Key'] || headers?.['idempotency-key'] || ''
|
||||
const idempotencySignature = isWrite ? `${method}:${url}:${finalBody ?? ''}` : ''
|
||||
const idempotencyKey = isWrite && !suppliedKey
|
||||
? (this.pendingIdempotencyKeys.get(idempotencySignature) || createIdempotencyKey())
|
||||
: suppliedKey
|
||||
if (isWrite && !suppliedKey) {
|
||||
this.pendingIdempotencyKeys.set(idempotencySignature, idempotencyKey)
|
||||
}
|
||||
|
||||
const config = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': NONCE,
|
||||
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
||||
...headers,
|
||||
},
|
||||
...rest,
|
||||
@@ -174,6 +201,9 @@ class ApiClient {
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
if (idempotencySignature !== '') {
|
||||
this.pendingIdempotencyKeys.delete(idempotencySignature)
|
||||
}
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error(`[API Error] ${endpoint}:`, error)
|
||||
|
||||
@@ -28,17 +28,17 @@ export const usersApiMethods = {
|
||||
return this.request('/identity/settings/test', { method: 'POST', body: JSON.stringify(data) })
|
||||
},
|
||||
|
||||
getCreditSettings() { return this.request('/credits/settings') },
|
||||
saveCreditSettings(data) { return this.request('/credits/settings', { method: 'POST', body: JSON.stringify(data) }) },
|
||||
getCreditSettings() { return this.request('/assets/settings') },
|
||||
saveCreditSettings(data) { return this.request('/assets/settings', { method: 'POST', body: JSON.stringify(data) }) },
|
||||
listCreditUsers(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString()
|
||||
return this.request('/credits/users' + (qs ? `?${qs}` : ''))
|
||||
return this.request('/assets/accounts' + (qs ? `?${qs}` : ''))
|
||||
},
|
||||
adjustUserCredits(id, delta, reason) {
|
||||
return this.request(`/credits/users/${id}/adjust`, { method: 'POST', body: JSON.stringify({ delta, reason }) })
|
||||
adjustUserCredits(id, asset, delta, remark) {
|
||||
return this.request(`/assets/accounts/${id}/adjust`, { method: 'POST', body: JSON.stringify({ asset, delta, remark }) })
|
||||
},
|
||||
getCreditLog(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString()
|
||||
return this.request('/credits/log' + (qs ? `?${qs}` : ''))
|
||||
return this.request('/assets/transactions' + (qs ? `?${qs}` : ''))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<h2 class=”aksrt-page-title”>积分配置</h2>
|
||||
<p class=”aksrt-page-desc”>
|
||||
配置积分规则与发放策略。可自定义货币名称(如”金币”、”魔石”等)。用户余额与手工调整已迁移到 WordPress 原生”用户”菜单中管理。
|
||||
<h2 class="aksrt-page-title">积分配置</h2>
|
||||
<p class="aksrt-page-desc">
|
||||
配置积分规则与发放策略。可自定义货币名称(如”金币”、”魔石”等)。用户余额与手工调整在 WordPress 原生”用户”菜单中管理。
|
||||
</p>
|
||||
|
||||
<n-card class="aksrt-section-card">
|
||||
@@ -23,6 +23,35 @@
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card title="统一资产账本" class="aksrt-section-card">
|
||||
<n-space vertical :size="12">
|
||||
<n-space wrap>
|
||||
<n-select v-model:value="ledgerFilters.asset" :options="assetOptions" clearable placeholder="资产" style="width: 130px" />
|
||||
<n-input v-model:value="ledgerFilters.transaction_id" placeholder="交易号" clearable style="width: 130px" />
|
||||
<n-input v-model:value="ledgerFilters.operation_key" placeholder="幂等键" clearable style="width: 240px" />
|
||||
<n-input v-model:value="ledgerFilters.ref_type" placeholder="引用类型" clearable style="width: 130px" />
|
||||
<n-input v-model:value="ledgerFilters.ref_key" placeholder="引用键" clearable style="width: 180px" />
|
||||
<n-input v-model:value="ledgerFilters.actor_user_id" placeholder="操作者 ID" clearable style="width: 130px" />
|
||||
<n-button type="primary" :loading="ledgerLoading" @click="searchLedger">查询</n-button>
|
||||
<n-button @click="resetLedger">重置</n-button>
|
||||
</n-space>
|
||||
<n-data-table
|
||||
:columns="ledgerColumns"
|
||||
:data="ledgerRows"
|
||||
:loading="ledgerLoading"
|
||||
:row-key="row => row.id"
|
||||
:scroll-x="1600"
|
||||
size="small"
|
||||
/>
|
||||
<n-pagination
|
||||
v-model:page="ledgerPage"
|
||||
:page-count="ledgerPageCount"
|
||||
:page-size="20"
|
||||
@update:page="loadLedger"
|
||||
/>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-section-card">
|
||||
<div class="aksrt-section-header"><h3>每日签到</h3></div>
|
||||
<n-form :model="cfg.daily_signin" label-placement="left" label-width="200" class="aksrt-form">
|
||||
@@ -97,7 +126,7 @@
|
||||
<n-form-item :label="`默认积分价参考(${cfg.currency_name || '积分'})`">
|
||||
<n-input-number v-model:value="cfg.download_default_cost" :min="0" :max="100000" />
|
||||
<template #feedback>
|
||||
<span class="aksrt-hint-text">兼容字段。实际价格以文章「付费内容设置」的积分价/现金价为准;下载解锁走收银台订单,不按次扣费。</span>
|
||||
<span class="aksrt-hint-text">实际价格以文章「付费内容设置」的积分价或现金价为准;下载解锁走收银台订单,不按次扣费。</span>
|
||||
</template>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
@@ -135,10 +164,88 @@ const cfg = reactive({
|
||||
})
|
||||
const streakJson = ref('{}')
|
||||
const saving = ref(false)
|
||||
const ledgerLoading = ref(false)
|
||||
const ledgerRows = ref([])
|
||||
const ledgerPage = ref(1)
|
||||
const ledgerPageCount = ref(1)
|
||||
const ledgerFilters = reactive({
|
||||
asset: null,
|
||||
transaction_id: '',
|
||||
operation_key: '',
|
||||
ref_type: '',
|
||||
ref_key: '',
|
||||
actor_user_id: '',
|
||||
})
|
||||
const assetOptions = [
|
||||
{ label: '积分', value: 'points' },
|
||||
{ label: '余额', value: 'balance' },
|
||||
]
|
||||
const ledgerColumns = [
|
||||
{ title: '交易号', key: 'transaction_label', width: 100, fixed: 'left' },
|
||||
{ title: '幂等键', key: 'operation_key', width: 260, ellipsis: { tooltip: true } },
|
||||
{ title: '资产', key: 'assets_label', width: 100 },
|
||||
{ title: '原因', key: 'reason', width: 150, ellipsis: { tooltip: true } },
|
||||
{ title: '业务引用', key: 'reference_label', width: 200, ellipsis: { tooltip: true } },
|
||||
{ title: '操作者', key: 'actor_label', width: 100 },
|
||||
{ title: '增减 / 手续费', key: 'delta_label', width: 180, ellipsis: { tooltip: true } },
|
||||
{ title: '变动后余额', key: 'balance_label', width: 150, ellipsis: { tooltip: true } },
|
||||
{ title: '备注', key: 'remark', width: 180, ellipsis: { tooltip: true } },
|
||||
{ title: 'UTC 时间', key: 'created_at', width: 190 },
|
||||
]
|
||||
|
||||
function formatAssetValue(asset, value) {
|
||||
if (asset === 'balance') return typeof value === 'string' && /^[+-]?\d+\.\d{2}$/.test(value) ? value : '0.00'
|
||||
const raw = String(value ?? '0')
|
||||
return /^-?\d+$/.test(raw) ? raw : '0'
|
||||
}
|
||||
|
||||
async function loadLedger() {
|
||||
ledgerLoading.value = true
|
||||
try {
|
||||
const params = { page: ledgerPage.value, per_page: 20 }
|
||||
Object.entries(ledgerFilters).forEach(([key, value]) => {
|
||||
if (value !== null && String(value).trim() !== '') params[key] = value
|
||||
})
|
||||
const response = await api.getCreditLog(params)
|
||||
const data = response?.data || response || {}
|
||||
ledgerRows.value = (data.items || []).map((row) => {
|
||||
const entries = Array.isArray(row.entries) ? row.entries : []
|
||||
return {
|
||||
...row,
|
||||
transaction_label: `#${row.id}`,
|
||||
assets_label: (row.assets || []).join(' / '),
|
||||
reference_label: [row.ref_type, row.ref_key].filter(Boolean).join(':') || '—',
|
||||
actor_label: row.actor_user_id ? `#${row.actor_user_id}` : '系统',
|
||||
delta_label: entries.map((entry) => {
|
||||
const value = formatAssetValue(entry.asset, entry.delta)
|
||||
const signed = entry.asset === 'points' && !value.startsWith('-') && value !== '0' ? `+${value}` : value
|
||||
return `${entry.asset}:${signed}`
|
||||
}).join(';'),
|
||||
balance_label: entries.map((entry) => `${entry.asset}:${formatAssetValue(entry.asset, entry.balance_after)}`).join(';'),
|
||||
}
|
||||
})
|
||||
ledgerPageCount.value = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.per_page || 20)))
|
||||
} catch (e) {
|
||||
message.error(e.message || '加载账本失败')
|
||||
} finally {
|
||||
ledgerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function searchLedger() {
|
||||
ledgerPage.value = 1
|
||||
loadLedger()
|
||||
}
|
||||
|
||||
function resetLedger() {
|
||||
Object.assign(ledgerFilters, { asset: null, transaction_id: '', operation_key: '', ref_type: '', ref_key: '', actor_user_id: '' })
|
||||
searchLedger()
|
||||
}
|
||||
|
||||
async function loadCfg() {
|
||||
try {
|
||||
const data = await api.getCreditSettings()
|
||||
const response = await api.getCreditSettings()
|
||||
const data = response?.data || response || {}
|
||||
Object.assign(cfg, data)
|
||||
streakJson.value = JSON.stringify(cfg.daily_signin?.streak || {}, null, 0)
|
||||
} catch (e) {
|
||||
@@ -163,5 +270,6 @@ async function saveCfg() {
|
||||
|
||||
onMounted(() => {
|
||||
loadCfg()
|
||||
loadLedger()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -22,10 +22,16 @@
|
||||
<n-switch v-model:value="settings.enabled_types.download_ready" />
|
||||
</n-form-item>
|
||||
<n-form-item label="积分增加通知">
|
||||
<n-switch v-model:value="settings.enabled_types.credits_added" />
|
||||
<n-switch v-model:value="settings.enabled_types.asset_points_increase" />
|
||||
</n-form-item>
|
||||
<n-form-item label="积分扣减通知">
|
||||
<n-switch v-model:value="settings.enabled_types.credits_deducted" />
|
||||
<n-switch v-model:value="settings.enabled_types.asset_points_decrease" />
|
||||
</n-form-item>
|
||||
<n-form-item label="余额增加通知">
|
||||
<n-switch v-model:value="settings.enabled_types.asset_balance_increase" />
|
||||
</n-form-item>
|
||||
<n-form-item label="余额扣减通知">
|
||||
<n-switch v-model:value="settings.enabled_types.asset_balance_decrease" />
|
||||
</n-form-item>
|
||||
<n-form-item label="邮箱待验证提醒">
|
||||
<n-switch v-model:value="settings.enabled_types.verify_email" />
|
||||
@@ -90,8 +96,10 @@ const settings = reactive({
|
||||
comment_reply: true,
|
||||
comment_approved: true,
|
||||
download_ready: true,
|
||||
credits_added: true,
|
||||
credits_deducted: true,
|
||||
asset_points_increase: true,
|
||||
asset_points_decrease: true,
|
||||
asset_balance_increase: true,
|
||||
asset_balance_decrease: true,
|
||||
verify_email: true,
|
||||
pending_comments: true,
|
||||
upload_moderation: true,
|
||||
|
||||
@@ -55,10 +55,10 @@
|
||||
<n-select v-model:value="createForm.type" :options="typeOptions" />
|
||||
</n-form-item>
|
||||
<n-form-item label="卡密">
|
||||
<n-input v-model:value="createForm.code" placeholder="手动填写唯一卡密" />
|
||||
<n-input v-model:value="createForm.password" placeholder="手动填写唯一卡密" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="createForm.type === 'balance'" label="面额">
|
||||
<n-input-number v-model:value="createForm.amount" :min="0.01" :precision="2" style="width: 100%;" />
|
||||
<n-input v-model:value="createForm.amount" inputmode="decimal" placeholder="例如 10.00" />
|
||||
</n-form-item>
|
||||
<n-grid v-if="createForm.type === 'vip'" cols="2" :x-gap="12">
|
||||
<n-grid-item>
|
||||
@@ -101,7 +101,7 @@
|
||||
</n-grid-item>
|
||||
<n-grid-item v-if="batchForm.type === 'balance'">
|
||||
<n-form-item label="面额">
|
||||
<n-input-number v-model:value="batchForm.amount" :min="0.01" :precision="2" style="width: 100%;" />
|
||||
<n-input v-model:value="batchForm.amount" inputmode="decimal" placeholder="例如 10.00" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item v-if="batchForm.type === 'credits'">
|
||||
@@ -185,8 +185,8 @@ const filters = reactive({
|
||||
|
||||
const createForm = reactive({
|
||||
type: 'balance',
|
||||
code: '',
|
||||
amount: 10,
|
||||
password: '',
|
||||
amount: '10.00',
|
||||
level: 1,
|
||||
days: 30,
|
||||
points: 100,
|
||||
@@ -197,7 +197,7 @@ const createForm = reactive({
|
||||
const batchForm = reactive({
|
||||
type: 'balance',
|
||||
count: 20,
|
||||
amount: 10,
|
||||
amount: '10.00',
|
||||
level: 1,
|
||||
days: 30,
|
||||
points: 100,
|
||||
@@ -265,7 +265,7 @@ const pagination = computed(() => ({
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', key: 'id', width: 70 },
|
||||
{ title: '卡密', key: 'code', minWidth: 170, ellipsis: { tooltip: true } },
|
||||
{ title: '卡密', key: 'password', minWidth: 170, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '类型',
|
||||
key: 'type_label',
|
||||
@@ -408,9 +408,8 @@ async function handleCreate() {
|
||||
method: 'POST',
|
||||
data: {
|
||||
type: createForm.type,
|
||||
code: createForm.code,
|
||||
password: createForm.code,
|
||||
amount: createForm.amount,
|
||||
password: createForm.password,
|
||||
amount: String(createForm.amount ?? ''),
|
||||
level: createForm.level,
|
||||
days: createForm.days,
|
||||
points: createForm.points,
|
||||
@@ -420,7 +419,7 @@ async function handleCreate() {
|
||||
})
|
||||
message.success('充值卡已创建')
|
||||
createVisible.value = false
|
||||
createForm.code = ''
|
||||
createForm.password = ''
|
||||
createForm.note = ''
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
@@ -438,7 +437,7 @@ async function handleBatchCreate() {
|
||||
data: {
|
||||
type: batchForm.type,
|
||||
count: batchForm.count,
|
||||
amount: batchForm.amount,
|
||||
amount: String(batchForm.amount ?? ''),
|
||||
level: batchForm.level,
|
||||
days: batchForm.days,
|
||||
points: batchForm.points,
|
||||
@@ -461,7 +460,7 @@ async function handleBatchCreate() {
|
||||
function handleDisable(row) {
|
||||
dialog.warning({
|
||||
title: '确认禁用',
|
||||
content: `确定要禁用充值卡 ${row.code} 吗?`,
|
||||
content: `确定要禁用充值卡 ${row.password} 吗?`,
|
||||
positiveText: '禁用',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
@@ -488,7 +487,7 @@ async function handleExport() {
|
||||
const header = ['ID', '卡号', '面额', '余额', '状态', '使用人', '创建时间', '过期时间', '备注']
|
||||
const lines = items.map((row) => [
|
||||
row.id,
|
||||
row.code,
|
||||
row.password,
|
||||
row.amount,
|
||||
row.balance,
|
||||
row.status_label,
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="返佣管理" desc="按状态、类型和时间筛选返佣记录,自定义列展示并在详情中查看处理历史。" />
|
||||
|
||||
<div class="aksrt-rebate-page__stats">
|
||||
<n-card v-for="card in statCards" :key="card.key" size="small">
|
||||
<n-statistic :label="card.label" :value="stats[card.key] || 0" />
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<n-card size="small" title="筛选与列控制">
|
||||
<n-space vertical :size="12">
|
||||
<n-grid cols="1 s:2 m:5" :x-gap="12" :y-gap="12" responsive="screen">
|
||||
<n-grid-item>
|
||||
<n-input v-model:value="filters.keyword" clearable placeholder="搜索订单号、用户、备注" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="状态" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-select v-model:value="filters.type" :options="typeOptions" placeholder="类型" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-date-picker v-model:value="filters.dateRange" type="daterange" clearable style="width: 100%;" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-space>
|
||||
<n-button type="primary" @click="handleSearch">筛选</n-button>
|
||||
<n-button @click="resetFilters">重置</n-button>
|
||||
</n-space>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-checkbox-group v-model:value="visibleColumnKeys">
|
||||
<n-space wrap>
|
||||
<n-checkbox v-for="column in columnChoices" :key="column.value" :value="column.value">
|
||||
{{ column.label }}
|
||||
</n-checkbox>
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card class="aksrt-rebate-page__card" size="small" title="返佣台账">
|
||||
<n-data-table
|
||||
:columns="tableColumns"
|
||||
:data="rows"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
:pagination="pagination"
|
||||
:row-key="(row) => row.id"
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<RebateDetailDrawer
|
||||
v-model:show="drawerVisible"
|
||||
:rebate-id="currentRebateId"
|
||||
@updated="loadData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { NButton, NSpace, NTag, useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import RebateDetailDrawer from './components/RebateDetailDrawer.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const rows = ref([])
|
||||
const drawerVisible = ref(false)
|
||||
const currentRebateId = ref(null)
|
||||
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
pending: 0,
|
||||
settled: 0,
|
||||
total_amount: 0,
|
||||
settled_amount: 0,
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
status: '',
|
||||
type: '',
|
||||
dateRange: null,
|
||||
})
|
||||
|
||||
const pager = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
itemCount: 0,
|
||||
})
|
||||
|
||||
const optionsState = reactive({
|
||||
statuses: [],
|
||||
types: [],
|
||||
})
|
||||
|
||||
const statCards = [
|
||||
{ key: 'total', label: '总记录' },
|
||||
{ key: 'pending', label: '待结算' },
|
||||
{ key: 'settled', label: '已结算' },
|
||||
{ key: 'total_amount', label: '总返佣' },
|
||||
{ key: 'settled_amount', label: '已结算金额' },
|
||||
]
|
||||
|
||||
const statusOptions = computed(() => optionsState.statuses.length
|
||||
? optionsState.statuses
|
||||
: [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '待结算', value: 'pending' },
|
||||
{ label: '已结算', value: 'settled' },
|
||||
{ label: '已取消', value: 'cancelled' },
|
||||
])
|
||||
|
||||
const typeOptions = computed(() => optionsState.types.length
|
||||
? optionsState.types
|
||||
: [
|
||||
{ label: '全部类型', value: '' },
|
||||
{ label: '订单返佣', value: 'order' },
|
||||
])
|
||||
|
||||
const columnChoices = [
|
||||
{ label: '来源用户', value: 'source_user' },
|
||||
{ label: '创建时间', value: 'created_at' },
|
||||
{ label: '结算时间', value: 'settled_at' },
|
||||
{ label: '备注', value: 'note' },
|
||||
{ label: '处理记录数', value: 'history_count' },
|
||||
]
|
||||
|
||||
const visibleColumnKeys = ref(['source_user', 'created_at', 'settled_at', 'history_count'])
|
||||
|
||||
const baseColumns = computed(() => {
|
||||
const columns = [
|
||||
{ title: 'ID', key: 'id', width: 70 },
|
||||
{ title: '订单号', key: 'order_number', minWidth: 180, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '返佣对象',
|
||||
key: 'user',
|
||||
width: 160,
|
||||
render: (row) => row.user?.display_name || '-',
|
||||
},
|
||||
{
|
||||
title: '返佣金额',
|
||||
key: 'amount',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
const typeMap = { pending: 'warning', settled: 'success', cancelled: 'default' }
|
||||
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status_label)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
key: 'type',
|
||||
width: 100,
|
||||
render: (row) => row.type_label || row.type,
|
||||
},
|
||||
]
|
||||
|
||||
if (visibleColumnKeys.value.includes('source_user')) {
|
||||
columns.push({
|
||||
title: '来源用户',
|
||||
key: 'source_user',
|
||||
width: 160,
|
||||
render: (row) => row.source_user?.display_name || '-',
|
||||
})
|
||||
}
|
||||
|
||||
if (visibleColumnKeys.value.includes('created_at')) {
|
||||
columns.push({ title: '创建时间', key: 'created_at', width: 170 })
|
||||
}
|
||||
|
||||
if (visibleColumnKeys.value.includes('settled_at')) {
|
||||
columns.push({
|
||||
title: '结算时间',
|
||||
key: 'settled_at',
|
||||
width: 170,
|
||||
render: (row) => row.settled_at || '-',
|
||||
})
|
||||
}
|
||||
|
||||
if (visibleColumnKeys.value.includes('note')) {
|
||||
columns.push({
|
||||
title: '备注',
|
||||
key: 'note',
|
||||
minWidth: 160,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => row.note || '-',
|
||||
})
|
||||
}
|
||||
|
||||
if (visibleColumnKeys.value.includes('history_count')) {
|
||||
columns.push({ title: '处理记录数', key: 'history_count', width: 100 })
|
||||
}
|
||||
|
||||
columns.push({
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
||||
h(NButton, {
|
||||
size: 'tiny',
|
||||
type: 'primary',
|
||||
quaternary: true,
|
||||
onClick: () => openDrawer(row.id),
|
||||
}, () => '详情'),
|
||||
]),
|
||||
})
|
||||
|
||||
return columns
|
||||
})
|
||||
|
||||
const tableColumns = computed(() => baseColumns.value)
|
||||
|
||||
const pagination = computed(() => ({
|
||||
page: pager.page,
|
||||
pageSize: pager.pageSize,
|
||||
itemCount: pager.itemCount,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
onUpdatePage: (page) => {
|
||||
pager.page = page
|
||||
loadData()
|
||||
},
|
||||
onUpdatePageSize: (pageSize) => {
|
||||
pager.pageSize = pageSize
|
||||
pager.page = 1
|
||||
loadData()
|
||||
},
|
||||
}))
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
function formatDateValue(value) {
|
||||
const date = new Date(value)
|
||||
const year = date.getFullYear()
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function buildParams() {
|
||||
const params = {
|
||||
page: pager.page,
|
||||
per_page: pager.pageSize,
|
||||
}
|
||||
if (filters.keyword) params.keyword = filters.keyword
|
||||
if (filters.status) params.status = filters.status
|
||||
if (filters.type) params.type = filters.type
|
||||
if (Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
|
||||
params.date_from = formatDateValue(filters.dateRange[0])
|
||||
params.date_to = formatDateValue(filters.dateRange[1])
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.request('/payment/rebate/log', { params: buildParams() })
|
||||
const data = res?.data || {}
|
||||
rows.value = data.items || []
|
||||
pager.itemCount = data.total || 0
|
||||
Object.assign(stats, data.stats || {})
|
||||
optionsState.statuses = data.options?.statuses || []
|
||||
optionsState.types = data.options?.types || []
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载返佣台账失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pager.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = ''
|
||||
filters.status = ''
|
||||
filters.type = ''
|
||||
filters.dateRange = null
|
||||
pager.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function openDrawer(id) {
|
||||
currentRebateId.value = id
|
||||
drawerVisible.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-rebate-page__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.aksrt-rebate-page__card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -49,7 +49,7 @@
|
||||
</div>
|
||||
<n-empty v-else size="small" description="暂无数据" />
|
||||
<div class="aksrt-revenue__chart-summary">
|
||||
<span>期间总计: <strong>{{ formatAmount(group.total, group.key) }}</strong></span>
|
||||
<span>期间总计: <strong>{{ formatAmount(group.total, group.key) }}</strong></span>
|
||||
<span>共 {{ group.count }} 笔</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,7 +91,7 @@
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="aksrt-revenue__stat">
|
||||
<strong>{{ withdrawalStats.total_paid.toFixed(2) }}</strong>
|
||||
<strong>{{ formatAmount(withdrawalStats.total_paid, 'cash') }}</strong>
|
||||
<p>已提现 (元)</p>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
@@ -106,16 +106,21 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
|
||||
import api from '@/api'
|
||||
|
||||
const emptyCurrencyStats = () => ({ total: 0, pending: 0, settled: 0, count: 0 })
|
||||
const revenueStats = ref({ cash: emptyCurrencyStats(), points: emptyCurrencyStats() })
|
||||
const withdrawalStats = ref({ total: 0, pending: 0, processing: 0, completed: 0, rejected: 0, total_paid: 0 })
|
||||
const emptyCurrencyStats = (currency) => ({
|
||||
total: currency === 'cash' ? '0.00' : 0,
|
||||
pending: currency === 'cash' ? '0.00' : 0,
|
||||
settled: currency === 'cash' ? '0.00' : 0,
|
||||
count: 0,
|
||||
})
|
||||
const revenueStats = ref({ cash: emptyCurrencyStats('cash'), points: emptyCurrencyStats('points') })
|
||||
const withdrawalStats = ref({ total: 0, pending: 0, processing: 0, completed: 0, rejected: 0, total_paid: '0.00' })
|
||||
const timeSeries = ref({ cash: [], points: [] })
|
||||
const granularity = ref('day')
|
||||
|
||||
const revenueSummary = computed(() => [
|
||||
{ key: 'cash-total', value: `${revenueStats.value.cash.total.toFixed(2)} 元`, label: '现金总收益' },
|
||||
{ key: 'cash-pending', value: `${revenueStats.value.cash.pending.toFixed(2)} 元`, label: '现金待结算' },
|
||||
{ key: 'cash-settled', value: `${revenueStats.value.cash.settled.toFixed(2)} 元`, label: '现金已结算' },
|
||||
{ key: 'cash-total', value: `${formatAmount(revenueStats.value.cash.total, 'cash')} 元`, label: '现金总收益' },
|
||||
{ key: 'cash-pending', value: `${formatAmount(revenueStats.value.cash.pending, 'cash')} 元`, label: '现金待结算' },
|
||||
{ key: 'cash-settled', value: `${formatAmount(revenueStats.value.cash.settled, 'cash')} 元`, label: '现金已结算' },
|
||||
{ key: 'cash-count', value: revenueStats.value.cash.count, label: '现金收益笔数' },
|
||||
{ key: 'points-total', value: `${formatAmount(revenueStats.value.points.total, 'points')}`, label: '积分总收益' },
|
||||
{ key: 'points-pending', value: `${formatAmount(revenueStats.value.points.pending, 'points')}`, label: '积分待结算' },
|
||||
@@ -134,21 +139,44 @@ const timeSeriesGroups = computed(() => [
|
||||
label: '积分收益',
|
||||
items: Array.isArray(timeSeries.value.points) ? timeSeries.value.points : [],
|
||||
},
|
||||
].map((group) => ({
|
||||
...group,
|
||||
total: group.items.reduce((sum, item) => sum + Number(item.amount || 0), 0),
|
||||
count: group.items.reduce((sum, item) => sum + Number(item.count || 0), 0),
|
||||
})))
|
||||
].map((group) => {
|
||||
const totalMinor = group.items.reduce((sum, item) => sum + amountMinor(item.amount, group.key), 0n)
|
||||
return {
|
||||
...group,
|
||||
total: formatMinor(totalMinor, group.key),
|
||||
count: group.items.reduce((sum, item) => sum + Number(item.count || 0), 0),
|
||||
}
|
||||
}))
|
||||
|
||||
const hasTimeSeries = computed(() => timeSeriesGroups.value.some((group) => group.items.length > 0))
|
||||
|
||||
function formatAmount(amount, currency) {
|
||||
return currency === 'points' ? `${Number(amount || 0).toFixed(0)} 积分` : `¥${Number(amount || 0).toFixed(2)}`
|
||||
return currency === 'points'
|
||||
? `${formatMinor(amountMinor(amount, currency), currency)} 积分`
|
||||
: `¥${formatMinor(amountMinor(amount, currency), currency)}`
|
||||
}
|
||||
|
||||
function amountMinor(amount, currency) {
|
||||
const raw = String(amount ?? '').trim()
|
||||
if (currency === 'points') return /^\d+$/.test(raw) ? BigInt(raw) : 0n
|
||||
const match = /^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/.exec(raw)
|
||||
return match ? (BigInt(match[1]) * 100n) + BigInt((match[2] || '').padEnd(2, '0') || '0') : 0n
|
||||
}
|
||||
|
||||
function formatMinor(minor, currency) {
|
||||
if (currency === 'points') return String(minor)
|
||||
return `${minor / 100n}.${String(minor % 100n).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getBarHeight(amount, items) {
|
||||
const maxAmount = Math.max(1, ...items.map((item) => Number(item.amount || 0)))
|
||||
return Math.max(2, (Number(amount || 0) / maxAmount) * 100)
|
||||
const currency = items === timeSeries.value.points ? 'points' : 'cash'
|
||||
const maxAmount = items.reduce((max, item) => {
|
||||
const current = amountMinor(item.amount, currency)
|
||||
return current > max ? current : max
|
||||
}, 0n)
|
||||
if (maxAmount <= 0n) return 2
|
||||
const scaled = Number((amountMinor(amount, currency) * 10000n) / maxAmount) / 100
|
||||
return Math.max(2, scaled)
|
||||
}
|
||||
|
||||
function formatLabel(date) {
|
||||
@@ -170,8 +198,8 @@ async function loadRevenue() {
|
||||
if (res?.data) {
|
||||
if (res.data.revenue) {
|
||||
revenueStats.value = {
|
||||
cash: { ...emptyCurrencyStats(), ...(res.data.revenue.cash || {}) },
|
||||
points: { ...emptyCurrencyStats(), ...(res.data.revenue.points || {}) },
|
||||
cash: { ...emptyCurrencyStats('cash'), ...(res.data.revenue.cash || {}) },
|
||||
points: { ...emptyCurrencyStats('points'), ...(res.data.revenue.points || {}) },
|
||||
}
|
||||
}
|
||||
if (res.data.withdrawal) {
|
||||
@@ -181,7 +209,7 @@ async function loadRevenue() {
|
||||
processing: Number(res.data.withdrawal.processing || 0),
|
||||
completed: Number(res.data.withdrawal.completed || 0),
|
||||
rejected: Number(res.data.withdrawal.rejected || 0),
|
||||
total_paid: Number(res.data.withdrawal.total_paid || 0),
|
||||
total_paid: String(res.data.withdrawal.total_paid || '0.00'),
|
||||
}
|
||||
}
|
||||
timeSeries.value = {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<n-spin :show="loading">
|
||||
<template v-if="detail.card">
|
||||
<n-descriptions bordered :column="1" size="small" label-placement="left">
|
||||
<n-descriptions-item label="卡号">{{ detail.card.code || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="卡密">{{ detail.card.password || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="面额">{{ detail.card.amount || 0 }}</n-descriptions-item>
|
||||
<n-descriptions-item label="余额">{{ detail.card.balance || 0 }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">{{ detail.card.status_label || detail.card.status }}</n-descriptions-item>
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
<template>
|
||||
<n-drawer :show="show" :width="720" @update:show="$emit('update:show', $event)">
|
||||
<n-drawer-content title="返佣详情" closable>
|
||||
<n-spin :show="loading">
|
||||
<template v-if="detail.record">
|
||||
<n-descriptions bordered :column="1" size="small" label-placement="left">
|
||||
<n-descriptions-item label="订单号">{{ detail.record.order_number || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="返佣对象">{{ detail.record.user?.display_name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="来源用户">{{ detail.record.source_user?.display_name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="返佣金额">{{ detail.record.amount || 0 }}</n-descriptions-item>
|
||||
<n-descriptions-item label="类型">{{ detail.record.type_label || detail.record.type }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">{{ detail.record.status_label || detail.record.status }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ detail.record.created_at || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="结算时间">{{ detail.record.settled_at || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<div class="aksrt-rebate-drawer__section">
|
||||
<div class="aksrt-rebate-drawer__title">备注</div>
|
||||
<n-alert type="info" :show-icon="false">
|
||||
{{ detail.record.note || '暂无备注' }}
|
||||
</n-alert>
|
||||
</div>
|
||||
|
||||
<div class="aksrt-rebate-drawer__section">
|
||||
<div class="aksrt-rebate-drawer__title">处理记录</div>
|
||||
<div v-if="detail.history?.length" class="aksrt-rebate-drawer__timeline">
|
||||
<div v-for="(entry, index) in detail.history" :key="index" class="aksrt-rebate-drawer__timeline-item">
|
||||
<div class="aksrt-rebate-drawer__timeline-head">
|
||||
<strong>{{ entry.event || '-' }}</strong>
|
||||
<span>{{ entry.time || '-' }}</span>
|
||||
</div>
|
||||
<p>{{ entry.note || '无备注' }}</p>
|
||||
<small>{{ entry.operator_name || '系统' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-else description="暂无处理记录" />
|
||||
</div>
|
||||
|
||||
<div v-if="detail.record.status === 'pending'" class="aksrt-rebate-drawer__section">
|
||||
<div class="aksrt-rebate-drawer__title">处理返佣</div>
|
||||
<n-space vertical :size="12">
|
||||
<n-input
|
||||
v-model:value="form.note"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="记录结算说明或取消原因"
|
||||
/>
|
||||
<n-space>
|
||||
<n-button type="primary" :loading="submitting === 'settle'" @click="handleAction('settle')">
|
||||
结算返佣
|
||||
</n-button>
|
||||
<n-button type="warning" secondary :loading="submitting === 'cancel'" @click="handleAction('cancel')">
|
||||
取消返佣
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-empty v-else description="暂无返佣详情" />
|
||||
</n-spin>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
rebateId: { type: [Number, String], default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show', 'updated'])
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const submitting = ref('')
|
||||
const detail = ref({ record: null, history: [] })
|
||||
const form = reactive({ note: '' })
|
||||
|
||||
watch(
|
||||
() => [props.show, props.rebateId],
|
||||
async ([visible, id]) => {
|
||||
if (visible && id) {
|
||||
await loadDetail()
|
||||
}
|
||||
if (!visible) {
|
||||
detail.value = { record: null, history: [] }
|
||||
form.note = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function loadDetail() {
|
||||
if (!props.rebateId) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.request(`/payment/rebate/${props.rebateId}`)
|
||||
detail.value = res?.data || { record: null, history: [] }
|
||||
form.note = ''
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载返佣详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAction(action) {
|
||||
if (!props.rebateId) {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = action
|
||||
try {
|
||||
await api.request(`/payment/rebate/${props.rebateId}/${action}`, {
|
||||
method: 'POST',
|
||||
data: { note: form.note },
|
||||
})
|
||||
message.success(action === 'settle' ? '返佣已结算' : '返佣已取消')
|
||||
await loadDetail()
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
message.error(error?.message || '处理失败')
|
||||
} finally {
|
||||
submitting.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-rebate-drawer__section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__timeline {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__timeline-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__timeline-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__timeline-item p {
|
||||
margin: 0 0 6px;
|
||||
color: #111827;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.aksrt-rebate-drawer__timeline-item small {
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
@@ -163,8 +163,10 @@ function getTypeTag(type) {
|
||||
system: 'info',
|
||||
comment_reply: 'success',
|
||||
comment_approved: 'success',
|
||||
credits_added: 'warning',
|
||||
credits_deducted: 'warning',
|
||||
asset_points_increase: 'warning',
|
||||
asset_points_decrease: 'warning',
|
||||
asset_balance_increase: 'success',
|
||||
asset_balance_decrease: 'error',
|
||||
download_ready: 'primary',
|
||||
verify_email: 'info',
|
||||
pending_comments: 'warning',
|
||||
@@ -178,8 +180,10 @@ function getTypeLabel(type) {
|
||||
system: '系统通知',
|
||||
comment_reply: '评论回复',
|
||||
comment_approved: '评论审核',
|
||||
credits_added: '积分增加',
|
||||
credits_deducted: '积分扣减',
|
||||
asset_points_increase: '积分增加',
|
||||
asset_points_decrease: '积分扣减',
|
||||
asset_balance_increase: '余额增加',
|
||||
asset_balance_decrease: '余额扣减',
|
||||
download_ready: '下载就绪',
|
||||
verify_email: '邮箱验证',
|
||||
pending_comments: '待审评论',
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
<n-form-item label="云失败转人工"><n-switch v-model:value="verifyForm.user_verify_allow_manual_fallback" /></n-form-item>
|
||||
<n-form-item label="认证会话有效期(分钟)"><n-input-number v-model:value="verifyForm.user_verify_session_ttl_minutes" :min="5" :max="1440" /></n-form-item>
|
||||
<n-form-item label="结果轮询间隔(秒)"><n-input-number v-model:value="verifyForm.user_verify_poll_interval_seconds" :min="3" :max="60" /></n-form-item>
|
||||
<n-form-item label="个人认证费用"><n-input-number v-model:value="verifyForm.user_verify_personal_fee" :min="0" :step="0.01" /></n-form-item>
|
||||
<n-form-item label="企业认证费用"><n-input-number v-model:value="verifyForm.user_verify_enterprise_fee" :min="0" :step="0.01" /></n-form-item>
|
||||
<n-form-item label="个人认证费用"><n-input v-model:value="verifyForm.user_verify_personal_fee" placeholder="0.00" /></n-form-item>
|
||||
<n-form-item label="企业认证费用"><n-input v-model:value="verifyForm.user_verify_enterprise_fee" placeholder="0.00" /></n-form-item>
|
||||
<n-form-item label="个人认证徽标文案">
|
||||
<n-input v-model:value="verifyForm.user_verify_personal_badge_text" placeholder="个人认证" />
|
||||
</n-form-item>
|
||||
@@ -174,8 +174,8 @@ const verifyDefaults = {
|
||||
user_verify_allow_manual_fallback: true,
|
||||
user_verify_session_ttl_minutes: 30,
|
||||
user_verify_poll_interval_seconds: 5,
|
||||
user_verify_personal_fee: 0,
|
||||
user_verify_enterprise_fee: 0,
|
||||
user_verify_personal_fee: '0.00',
|
||||
user_verify_enterprise_fee: '0.00',
|
||||
user_verify_personal_badge_text: '个人认证',
|
||||
user_verify_enterprise_badge_text: '企业认证',
|
||||
user_verify_disable_desc: '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员',
|
||||
|
||||
@@ -78,7 +78,6 @@ function createSectionRedirectRoutes() {
|
||||
const NATIVE_OPS_REDIRECTS = [
|
||||
{ path: '/payment/charge-card', title: '充值卡', nativeAdminPath: 'admin.php?page=aksrt_charge_cards' },
|
||||
{ path: '/payment/income', title: '创作分成运营', nativeAdminPath: 'admin.php?page=aksrt_income' },
|
||||
{ path: '/payment/rebate', title: '返佣管理', nativeAdminPath: 'admin.php?page=aksrt_rebate' },
|
||||
{ path: '/users/messages', title: '消息中心', nativeAdminPath: 'users.php?page=aksrt_messages' },
|
||||
{ path: '/users/referral-codes', title: '推广码运营', nativeAdminPath: 'users.php?page=aksrt_referral_codes' },
|
||||
{ path: '/users/ban', title: '禁封管理', nativeAdminPath: 'users.php?page=aksrt_user_ban&tab=restrictions' },
|
||||
|
||||
@@ -12,7 +12,7 @@ export const cardPassSchema = {
|
||||
card_pass_batch_size: 50,
|
||||
card_pass_max_redeem: 1,
|
||||
card_pass_expiry: 0,
|
||||
card_pass_amount: 10,
|
||||
card_pass_amount: '10.00',
|
||||
card_pass_vip_days: 30,
|
||||
card_pass_credits: 0,
|
||||
card_pass_redeem_page: '',
|
||||
@@ -40,7 +40,7 @@ export const cardPassSchema = {
|
||||
fields: [
|
||||
{ key: 'card_pass_max_redeem', label: '单卡最大兑换次数', type: 'number', default: 1, min: 1, max: 100, dependsOn: { key: 'card_pass_enabled', value: true } },
|
||||
{ key: 'card_pass_expiry', label: '有效期(天)', type: 'number', default: 0, min: 0, help: '0为永久有效', dependsOn: { key: 'card_pass_enabled', value: true } },
|
||||
{ key: 'card_pass_amount', label: '面额(元)', type: 'number', default: 10, min: 1, max: 9999, dependsOn: { key: 'card_pass_enabled', value: true } },
|
||||
{ key: 'card_pass_amount', label: '面额(元)', type: 'input', default: '10.00', min: 1, max: 9999, dependsOn: { key: 'card_pass_enabled', value: true } },
|
||||
{ key: 'card_pass_vip_days', label: 'VIP天数', type: 'number', default: 30, min: 1, max: 3650, dependsOn: { key: 'card_pass_enabled', value: true } },
|
||||
{ key: 'card_pass_credits', label: '赠送积分', type: 'number', default: 0, min: 0, max: 99999, dependsOn: { key: 'card_pass_enabled', value: true } },
|
||||
],
|
||||
|
||||
@@ -6,7 +6,6 @@ export const rechargeSchema = {
|
||||
defaults: {
|
||||
pay_balance_s: true,
|
||||
pay_balance_desc: '',
|
||||
pay_auto_to_balance: true,
|
||||
pay_balance_product: '30|30|入门包\n98|108|推荐包',
|
||||
pay_balance_product_name: '',
|
||||
pay_balance_price: 0,
|
||||
@@ -34,7 +33,6 @@ export const rechargeSchema = {
|
||||
fields: [
|
||||
{ key: 'pay_balance_s', label: '启用余额功能', type: 'switch', default: true, help: '开启后用户中心显示现金钱包,并默认允许余额支付(可在收款接口单独关闭)。充值需另配支付宝/微信等外部网关。' },
|
||||
{ key: 'pay_balance_desc', label: '充值说明', type: 'textarea', default: '', dependsOn: { key: 'pay_balance_s', value: true } },
|
||||
{ key: 'pay_auto_to_balance', label: '自动到账到余额', type: 'switch', default: true, dependsOn: { key: 'pay_balance_s', value: true }, help: '充值订单支付成功后自动入账钱包(卡密/管理员调整不受此开关限制)。' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -35,7 +35,7 @@ export const vipSchema = {
|
||||
vip_renew_desc: '',
|
||||
vip_renew_price_type: 'discount',
|
||||
vip_renew_discount: 8,
|
||||
vip_renew_reduce: 20,
|
||||
vip_renew_reduce: '20.00',
|
||||
vip_upgrade: true,
|
||||
vip_upgrade_desc: '',
|
||||
vip_upgrade_product: 'unit|1|按剩余天数升级\njump|199|升级永久二级会员\npermanent|299|永久一级升永久二级',
|
||||
@@ -126,7 +126,7 @@ export const vipSchema = {
|
||||
{ key: 'vip_renew_desc', label: '续费说明', type: 'textarea', default: '', dependsOn: { key: 'vip_renew', value: true } },
|
||||
{ key: 'vip_renew_price_type', label: '续费价格类型', type: 'radio', default: 'discount', options: [{ label: '相同价格', value: 'same' }, { label: '折扣价格', value: 'discount' }, { label: '优惠立减', value: 'reduce' }], dependsOn: { key: 'vip_renew', value: true } },
|
||||
{ key: 'vip_renew_discount', label: '续费折扣(折,如 8=八折)', type: 'number', default: 8, min: 1, max: 100, dependsOn: { key: 'vip_renew_price_type', value: 'discount' } },
|
||||
{ key: 'vip_renew_reduce', label: '续费减免金额', type: 'number', default: 20, min: 0, max: 9999, dependsOn: { key: 'vip_renew_price_type', value: 'reduce' } },
|
||||
{ key: 'vip_renew_reduce', label: '续费减免金额', type: 'input', sanitize_type: 'money_decimal', default: '20.00', min: 0, max: 9999, dependsOn: { key: 'vip_renew_price_type', value: 'reduce' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,10 +4,10 @@ export const withdrawalSchema = {
|
||||
icon: 'fa fas fa-money-bill',
|
||||
description: '配置提现门槛、手续费比例与每日限制。',
|
||||
defaults: {
|
||||
affiliate_min_withdrawal: 50,
|
||||
affiliate_min_withdrawal: '50.00',
|
||||
affiliate_withdrawal_fee: 5,
|
||||
affiliate_daily_withdrawal_count: 3,
|
||||
affiliate_daily_withdrawal_limit: 500,
|
||||
affiliate_daily_withdrawal_limit: '500.00',
|
||||
affiliate_withdrawal_reset_time: '00:00',
|
||||
affiliate_payout_channels: ['alipay', 'wechat'],
|
||||
},
|
||||
@@ -17,10 +17,10 @@ export const withdrawalSchema = {
|
||||
title: '提现设置',
|
||||
icon: 'fa fas fa-sliders-h',
|
||||
fields: [
|
||||
{ key: 'affiliate_min_withdrawal', label: '最低提现金额', type: 'number', default: 50, min: 0, max: 99999 },
|
||||
{ key: 'affiliate_min_withdrawal', label: '最低提现金额', type: 'input', sanitize_type: 'money_decimal', default: '50.00', help: '仅接受最多两位小数的十进制字符串。' },
|
||||
{ key: 'affiliate_withdrawal_fee', label: '提现手续费(%)', type: 'number', default: 5, min: 0, max: 100 },
|
||||
{ key: 'affiliate_daily_withdrawal_count', label: '每日可提现次数', type: 'number', default: 3, min: 0, max: 99999 },
|
||||
{ key: 'affiliate_daily_withdrawal_limit', label: '每日提现上限', type: 'number', default: 500, min: 0, max: 999999 },
|
||||
{ key: 'affiliate_daily_withdrawal_limit', label: '每日提现上限', type: 'input', sanitize_type: 'money_decimal', default: '500.00', help: '仅接受最多两位小数的十进制字符串。' },
|
||||
{ key: 'affiliate_withdrawal_reset_time', label: '重置时间', type: 'input', default: '00:00', help: '按站点 WordPress 时区重置,格式 HH:MM。' },
|
||||
{ key: 'affiliate_payout_channels', label: '可用收款渠道', type: 'checkbox', default: ['alipay', 'wechat'], sanitize_type: 'array', options: [{ label: '支付宝收款码', value: 'alipay' }, { label: '微信收款码', value: 'wechat' }] },
|
||||
],
|
||||
|
||||
@@ -141,8 +141,8 @@ export const pageSchema = {
|
||||
{ key: 'post_rewards_text', label: '打赏按钮文案', type: 'input', default: '赞赏', dependsOn: { key: 'post_rewards_s', value: true } },
|
||||
{ key: 'post_rewards_amounts', label: '快捷金额', type: 'input', default: '5,10,20,50,100', help: '使用英文逗号分隔', dependsOn: { key: 'post_rewards_s', value: true } },
|
||||
{ key: 'post_rewards_custom', label: '允许自定义金额', type: 'switch', default: true, dependsOn: { key: 'post_rewards_s', value: true } },
|
||||
{ key: 'post_rewards_min', label: '最低金额', type: 'number', default: 1, min: 0.01, max: 1000, step: 0.01, dependsOn: { key: 'post_rewards_s', value: true } },
|
||||
{ key: 'post_rewards_max', label: '最高金额', type: 'number', default: 1000, min: 1, max: 100000, step: 0.01, dependsOn: { key: 'post_rewards_s', value: true } },
|
||||
{ key: 'post_rewards_min', label: '最低金额', type: 'input', default: '1.00', min: 0.01, max: 1000, step: 0.01, dependsOn: { key: 'post_rewards_s', value: true } },
|
||||
{ key: 'post_rewards_max', label: '最高金额', type: 'input', default: '1000.00', min: 1, max: 100000, step: 0.01, dependsOn: { key: 'post_rewards_s', value: true } },
|
||||
{ key: 'article_maxheight_kg', label: '正文限高', type: 'switch', default: false, help: '开启后长文默认折叠,可展开全文' },
|
||||
{ key: 'article_maxheight', label: '限高像素', type: 'number', default: 1000, min: 200, max: 5000, dependsOn: { key: 'article_maxheight_kg', value: true } },
|
||||
],
|
||||
|
||||
@@ -27,8 +27,8 @@ export const submitSchema = {
|
||||
post_article_title_strlen_limit_max: 30,
|
||||
post_article_user: 0,
|
||||
post_article_pay_free_s: true,
|
||||
post_article_pay_price_min: 0,
|
||||
post_article_pay_price_max: 100,
|
||||
post_article_pay_price_min: '0.00',
|
||||
post_article_pay_price_max: '100.00',
|
||||
post_article_pay_vip_price_s: true,
|
||||
post_article_pay_vip_1_discount: 80,
|
||||
post_article_pay_vip_2_discount: 50,
|
||||
@@ -114,8 +114,8 @@ export const submitSchema = {
|
||||
icon: 'fa fas fa-database',
|
||||
fields: [
|
||||
{ key: 'post_article_pay_free_s', label: '允许设置免费', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
|
||||
{ key: 'post_article_pay_price_min', label: '最低价格', type: 'number', default: 0, min: 0, max: 9999, help: '投稿可设置的最低付费金额', dependsOn: { key: 'front_submit_enabled', value: true } },
|
||||
{ key: 'post_article_pay_price_max', label: '最高价格', type: 'number', default: 100, min: 0, max: 9999, help: '投稿可设置的最高付费金额', dependsOn: { key: 'front_submit_enabled', value: true } },
|
||||
{ key: 'post_article_pay_price_min', label: '最低价格', type: 'input', sanitize_type: 'money_decimal', default: '0.00', min: 0, max: 9999, help: '投稿可设置的最低付费金额', dependsOn: { key: 'front_submit_enabled', value: true } },
|
||||
{ key: 'post_article_pay_price_max', label: '最高价格', type: 'input', sanitize_type: 'money_decimal', default: '100.00', min: 0, max: 9999, help: '投稿可设置的最高付费金额', dependsOn: { key: 'front_submit_enabled', value: true } },
|
||||
{ key: 'post_article_pay_vip_price_s', label: '允许设置会员价', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
|
||||
{ key: 'post_article_pay_vip_1_discount', label: 'VIP1 折扣(%)', type: 'number', default: 80, min: 0, max: 100, help: 'VIP1 会员折扣百分比,80 即 8 折', dependsOn: { key: 'front_submit_enabled', value: true } },
|
||||
{ key: 'post_article_pay_vip_2_discount', label: 'VIP2 折扣(%)', type: 'number', default: 50, min: 0, max: 100, help: 'VIP2 会员折扣百分比,50 即 5 折', dependsOn: { key: 'front_submit_enabled', value: true } },
|
||||
|
||||
@@ -12,8 +12,8 @@ export const MEDAL_GET_TYPES = [
|
||||
{ label: '文章获赞', value: 'like' },
|
||||
{ label: '评论获赞', value: 'comment_like' },
|
||||
{ label: '内容阅读量', value: 'views' },
|
||||
{ label: '现金创作分成累计(元)', value: 'pay_income' },
|
||||
{ label: '推广佣金累计(元)', value: 'pay_rebate' },
|
||||
{ label: '现金创作分成累计(元)', value: 'creator_income' },
|
||||
{ label: '推广佣金累计(元)', value: 'affiliate_commission' },
|
||||
{ label: '仅手动授予', value: 'manually_add' },
|
||||
]
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ export const creditsSchema = {
|
||||
points_deduction_s: false,
|
||||
points_deduction_rate: 100,
|
||||
points_deduction_max: 50,
|
||||
points_pass_exchange_s: false,
|
||||
points_pass_exchange_desc: '',
|
||||
points_pass_exchange_only_password: true,
|
||||
asset_points_card_enabled: false,
|
||||
asset_points_card_description: '',
|
||||
asset_points_card_password_only: true,
|
||||
points_show_balance: true,
|
||||
points_show_log: true,
|
||||
points_expire_days: 0,
|
||||
@@ -95,9 +95,9 @@ export const creditsSchema = {
|
||||
title: '积分卡密兑换',
|
||||
icon: 'fa fas fa-ticket-alt',
|
||||
fields: [
|
||||
{ key: 'points_pass_exchange_s', label: '启用积分卡密兑换', type: 'switch', default: false, help: '对齐 legacy points_pass_exchange_s。后台充值卡可生成积分卡,用户中心兑换入账。' },
|
||||
{ key: 'points_pass_exchange_desc', label: '兑换说明', type: 'textarea', default: '', dependsOn: { key: 'points_pass_exchange_s', value: true } },
|
||||
{ key: 'points_pass_exchange_only_password', label: '仅密码兑换', type: 'switch', default: true, dependsOn: { key: 'points_pass_exchange_s', value: true }, help: '开启后只需输入卡密;关闭则需卡号+卡密。' },
|
||||
{ key: 'asset_points_card_enabled', label: '启用积分卡密兑换', type: 'switch', default: false, help: '后台充值卡可生成积分卡,用户中心兑换入账。' },
|
||||
{ key: 'asset_points_card_description', label: '兑换说明', type: 'textarea', default: '', dependsOn: { key: 'asset_points_card_enabled', value: true } },
|
||||
{ key: 'asset_points_card_password_only', label: '仅密码兑换', type: 'switch', default: true, dependsOn: { key: 'asset_points_card_enabled', value: true }, help: '开启后只需输入卡密;关闭则需卡号+卡密。' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,11 +7,11 @@ export const verifySchema = {
|
||||
user_verify_enabled: true,
|
||||
user_verify_public_display: true,
|
||||
user_verify_personal_badge_text: '个人认证',
|
||||
user_verify_personal_fee: 0,
|
||||
user_verify_personal_fee: '0.00',
|
||||
user_verify_personal_free_applications: 0,
|
||||
user_verify_personal_require_real_name: false,
|
||||
user_verify_enterprise_badge_text: '企业认证',
|
||||
user_verify_enterprise_fee: 0,
|
||||
user_verify_enterprise_fee: '0.00',
|
||||
user_verify_enterprise_free_applications: 0,
|
||||
user_verify_enterprise_require_real_name: false,
|
||||
user_verify_allow_apply: true,
|
||||
@@ -67,10 +67,10 @@ export const verifySchema = {
|
||||
default: '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员',
|
||||
dependsOn: { key: 'user_verify_allow_apply', value: false },
|
||||
},
|
||||
{ key: 'user_verify_personal_fee', label: '个人认证每次扣余额(元)', type: 'number', default: 0, min: 0, step: 0.01, dependsOn: { key: 'user_verify_allow_apply', value: true } },
|
||||
{ key: 'user_verify_personal_fee', label: '个人认证每次扣余额(元)', type: 'input', default: '0.00', help: '仅接受最多两位小数的十进制字符串。', dependsOn: { key: 'user_verify_allow_apply', value: true } },
|
||||
{ key: 'user_verify_personal_free_applications', label: '个人认证赠送初始次数', type: 'number', default: 0, min: 0, max: 9999, help: '每位用户前 N 次个人认证申请免费,之后按单次费用扣余额。', dependsOn: { key: 'user_verify_allow_apply', value: true } },
|
||||
{ key: 'user_verify_personal_require_real_name', label: '个人认证强制填写真实姓名', type: 'switch', default: false, dependsOn: { key: 'user_verify_allow_apply', value: true } },
|
||||
{ key: 'user_verify_enterprise_fee', label: '企业认证每次扣余额(元)', type: 'number', default: 0, min: 0, step: 0.01, dependsOn: { key: 'user_verify_allow_apply', value: true } },
|
||||
{ key: 'user_verify_enterprise_fee', label: '企业认证每次扣余额(元)', type: 'input', default: '0.00', help: '仅接受最多两位小数的十进制字符串。', dependsOn: { key: 'user_verify_allow_apply', value: true } },
|
||||
{ key: 'user_verify_enterprise_free_applications', label: '企业认证赠送初始次数', type: 'number', default: 0, min: 0, max: 9999, help: '每位用户前 N 次企业认证申请免费,之后按单次费用扣余额。', dependsOn: { key: 'user_verify_allow_apply', value: true } },
|
||||
{ key: 'user_verify_enterprise_require_real_name', label: '企业认证强制填写真实姓名', type: 'switch', default: false, dependsOn: { key: 'user_verify_allow_apply', value: true } },
|
||||
{
|
||||
|
||||
+16
-10
@@ -150,7 +150,7 @@
|
||||
if (data && data.vip_url) {
|
||||
extra = '<a class="btn-primary btn-sm" href="' + escapeAttr(data.vip_url) + '">' + tt('go_vip', '开通会员') + '</a>';
|
||||
}
|
||||
} else if (code === 'aksrt_credits_insufficient' || code === 'aksrt_balance_insufficient') {
|
||||
} else if (code === 'aksrt_points_insufficient' || code === 'aksrt_balance_insufficient') {
|
||||
title = tt('credit_low', '积分不够');
|
||||
const balance = typeof (data && data.balance) === 'number' ? data.balance : 0;
|
||||
const cost = typeof (data && data.cost) === 'number' ? data.cost : 0;
|
||||
@@ -355,15 +355,17 @@
|
||||
function showLowCreditModal(button) {
|
||||
const balance = parseInt(button.getAttribute('data-balance'), 10) || 0;
|
||||
const cost = parseInt(button.getAttribute('data-cost'), 10) || 0;
|
||||
renderError('', { balance: balance, cost: cost }, button, 'aksrt_credits_insufficient');
|
||||
renderError('', { balance: balance, cost: cost }, button, 'aksrt_points_insufficient');
|
||||
}
|
||||
|
||||
// ===== 收银台(内容购买) =====
|
||||
function restHeaders() {
|
||||
return {
|
||||
function restHeaders(idempotencyKey) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': window.aksrtData.restNonce || '',
|
||||
};
|
||||
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
|
||||
return headers;
|
||||
}
|
||||
|
||||
function payApiBase(apiKind) {
|
||||
@@ -467,7 +469,7 @@
|
||||
}
|
||||
const title = escapeHtml(preview.title || '');
|
||||
const typeLabel = escapeHtml(preview.pay_type_label || '');
|
||||
const balance = typeof preview.balance === 'number' ? preview.balance : 0;
|
||||
const balance = isPoints && Number.isSafeInteger(preview.balance) ? preview.balance : 0;
|
||||
const methods = Array.isArray(preview.payment_methods) ? preview.payment_methods : [];
|
||||
const couponAllowed = !isPoints && !!preview.coupon_allowed;
|
||||
const couponCodeVal = escapeAttr(preview.coupon_code || preview._coupon_code || '');
|
||||
@@ -477,7 +479,7 @@
|
||||
priceRows += '<div class="aksrt-cashier__row"><span>' + tt('price', '价格') + '</span><b>' + baseLabel + '</b></div>';
|
||||
}
|
||||
// VIP list price row when VIP reduced list amount.
|
||||
if (typeof preview.list_amount === 'number' && typeof preview.base === 'number' && preview.list_amount !== preview.base) {
|
||||
if (preview.list_amount !== preview.base) {
|
||||
priceRows += '<div class="aksrt-cashier__row"><span>' + tt('vip_price', '会员价') + '</span><b class="is-sale">' + listLabel + '</b></div>';
|
||||
} else if (preview.amount !== preview.base && !preview.rebate_discount && !preview.coupon_discount) {
|
||||
priceRows += '<div class="aksrt-cashier__row"><span>' + tt('vip_price', '会员价') + '</span><b class="is-sale">' + amountLabel + '</b></div>';
|
||||
@@ -527,12 +529,12 @@
|
||||
}
|
||||
body += '</div>';
|
||||
}
|
||||
const walletBal = typeof preview.wallet_balance === 'number'
|
||||
const walletBal = typeof preview.wallet_balance === 'string'
|
||||
? preview.wallet_balance
|
||||
: (typeof preview.balance === 'number' ? preview.balance : 0);
|
||||
: (typeof preview.balance === 'string' ? preview.balance : '0.00');
|
||||
const hasBalanceMethod = methods.some(function (m) { return (m && m.id) === 'balance'; });
|
||||
if (hasBalanceMethod) {
|
||||
body += '<div class="aksrt-cashier__box"><div class="aksrt-cashier__row"><span>' + tt('my_wallet', '我的余额') + '</span><b class="is-ok">¥' + Number(walletBal).toFixed(2) + '</b></div></div>';
|
||||
body += '<div class="aksrt-cashier__box"><div class="aksrt-cashier__row"><span>' + tt('my_wallet', '我的余额') + '</span><b class="is-ok">¥' + escapeHtml(walletBal) + '</b></div></div>';
|
||||
if (preview.balance_enough === false) {
|
||||
body += '<div class="aksrt-cashier__warn">' + tt('wallet_short', '余额不足,请先充值或选择其他支付方式') + '</div>';
|
||||
if (preview.credits_url) {
|
||||
@@ -621,10 +623,13 @@
|
||||
if (pt) payload.price_type = pt;
|
||||
if (mode === 'cash' && couponCode) payload.coupon_code = couponCode;
|
||||
|
||||
if (!button.dataset.aksrtOperationKey) {
|
||||
button.dataset.aksrtOperationKey = window.AksrtRequest.createIdempotencyKey();
|
||||
}
|
||||
fetch(REST_BASE + payApiBase(apiKind) + postId, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: restHeaders(),
|
||||
headers: restHeaders(button.dataset.aksrtOperationKey),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function (resp) { return resp.json().then(function (j) { return { ok: resp.ok, status: resp.status, json: j }; }); })
|
||||
@@ -642,6 +647,7 @@
|
||||
return;
|
||||
}
|
||||
const data = (r.json && r.json.data) ? r.json.data : r.json;
|
||||
delete button.dataset.aksrtOperationKey;
|
||||
if (data && data.redirect && (data.payment_url || data.qr_content)) {
|
||||
if (data.payment_url) {
|
||||
window.open(data.payment_url, '_blank');
|
||||
|
||||
@@ -13,6 +13,23 @@
|
||||
return window.aksrtData || window.aksrt || {};
|
||||
}
|
||||
|
||||
function createIdempotencyKey() {
|
||||
if (window.crypto && typeof window.crypto.randomUUID === "function") {
|
||||
return window.crypto.randomUUID();
|
||||
}
|
||||
if (!window.crypto || typeof window.crypto.getRandomValues !== "function") {
|
||||
throw new Error("当前浏览器无法生成安全的操作标识");
|
||||
}
|
||||
var bytes = new Uint8Array(16);
|
||||
window.crypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
var hex = Array.prototype.map.call(bytes, function (value) {
|
||||
return value.toString(16).padStart(2, "0");
|
||||
}).join("");
|
||||
return hex.slice(0, 8) + "-" + hex.slice(8, 12) + "-" + hex.slice(12, 16) + "-" + hex.slice(16, 20) + "-" + hex.slice(20);
|
||||
}
|
||||
|
||||
function restBase() {
|
||||
var config = data();
|
||||
var raw = config.restUrl || config.apiUrl || config.rest_url || "";
|
||||
@@ -144,6 +161,7 @@
|
||||
window.AksrtRequest = {
|
||||
url: url,
|
||||
request: request,
|
||||
createIdempotencyKey: createIdempotencyKey,
|
||||
get: function (path, options) {
|
||||
return request(path, Object.assign({}, options || {}, { method: "GET" }));
|
||||
},
|
||||
|
||||
+71
-21
@@ -16,6 +16,28 @@
|
||||
}
|
||||
var activePoll = null;
|
||||
|
||||
function operationKey(owner) {
|
||||
if (!owner.dataset.aksrtOperationKey) {
|
||||
owner.dataset.aksrtOperationKey = window.AksrtRequest.createIdempotencyKey();
|
||||
}
|
||||
return owner.dataset.aksrtOperationKey;
|
||||
}
|
||||
|
||||
function resetOperationKey(owner) {
|
||||
delete owner.dataset.aksrtOperationKey;
|
||||
}
|
||||
|
||||
function resetOperationKeyOnChange(owner) {
|
||||
owner.addEventListener('input', function () { resetOperationKey(owner); });
|
||||
owner.addEventListener('change', function () { resetOperationKey(owner); });
|
||||
}
|
||||
|
||||
function balanceMinor(value) {
|
||||
var match = /^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/.exec(String(value || '').trim());
|
||||
if (!match) return null;
|
||||
return (BigInt(match[1]) * 100n) + BigInt((match[2] || '').padEnd(2, '0') || '0');
|
||||
}
|
||||
|
||||
function api(path, options) {
|
||||
return window.AksrtRequest.request(path, options || {}).then(function (response) {
|
||||
return {
|
||||
@@ -218,6 +240,7 @@
|
||||
Array.prototype.forEach.call(roots, function (root) {
|
||||
var tip = root.querySelector('[data-aksrt-card-tip]');
|
||||
var busy = false;
|
||||
resetOperationKeyOnChange(root);
|
||||
|
||||
function setTip(text, isError) {
|
||||
if (!tip) return;
|
||||
@@ -255,6 +278,7 @@
|
||||
|
||||
api('/pay/card-pass/redeem', {
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': operationKey(root) },
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (r) {
|
||||
busy = false;
|
||||
@@ -264,6 +288,7 @@
|
||||
return;
|
||||
}
|
||||
var data = (r.json && r.json.data) ? r.json.data : r.json;
|
||||
resetOperationKey(root);
|
||||
if (type === 'vip' && window.AksrtModalRuntime && typeof window.AksrtModalRuntime.invalidate === 'function') {
|
||||
window.AksrtModalRuntime.invalidate(['vip', 'orders']);
|
||||
}
|
||||
@@ -278,7 +303,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 余额充值 / 转账 / 积分兑余额(legacy 资产中心) =====
|
||||
// ===== 余额充值 / 转账 / 余额兑积分 =====
|
||||
function openModal(el) {
|
||||
if (!el) return;
|
||||
el.hidden = false;
|
||||
@@ -344,21 +369,23 @@
|
||||
});
|
||||
});
|
||||
if (!form) return;
|
||||
resetOperationKeyOnChange(form);
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
var pkgInput = form.querySelector('input[name="balance_package"]:checked') || form.querySelector('input[name="balance_package"]');
|
||||
var pkg = pkgInput ? pkgInput.value : 'custom';
|
||||
var customEl = form.querySelector('input[name="custom_price"]');
|
||||
var custom = customEl ? parseFloat(customEl.value || '0') : 0;
|
||||
if (custom > 0) {
|
||||
var custom = customEl ? String(customEl.value || '').trim() : '';
|
||||
var validCustom = /^(0|[1-9]\d*)(?:\.\d{1,2})?$/.test(custom) && custom !== '0' && custom !== '0.0' && custom !== '0.00';
|
||||
if (validCustom) {
|
||||
pkg = 'custom';
|
||||
}
|
||||
if ((!pkg || pkg === 'custom') && !(custom > 0) && !pkgInput) {
|
||||
if ((!pkg || pkg === 'custom') && !validCustom && !pkgInput) {
|
||||
notyf('请选择或输入充值金额', 'danger');
|
||||
return;
|
||||
}
|
||||
if (pkg === 'custom' && !(custom > 0)) {
|
||||
if (pkg === 'custom' && !validCustom) {
|
||||
notyf('请输入有效充值金额', 'danger');
|
||||
return;
|
||||
}
|
||||
@@ -368,6 +395,7 @@
|
||||
notyf('正在创建充值订单…', 'load', '', 'aksrt-balance-charge-toast');
|
||||
api('/pay/balance/charge', {
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': operationKey(form) },
|
||||
body: JSON.stringify({
|
||||
package: pkg,
|
||||
custom_price: custom,
|
||||
@@ -380,6 +408,7 @@
|
||||
return;
|
||||
}
|
||||
var data = (r.json && r.json.data) ? r.json.data : r.json;
|
||||
resetOperationKey(form);
|
||||
if (data && data.redirect && (data.payment_url || data.qr_content)) {
|
||||
if (data.payment_url) window.open(data.payment_url, '_blank');
|
||||
notyf(data.message || '请完成支付,支付成功后将自动刷新。', 'info', 5, 'aksrt-balance-charge-toast');
|
||||
@@ -478,6 +507,7 @@
|
||||
});
|
||||
}
|
||||
if (!form) return;
|
||||
resetOperationKeyOnChange(form);
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
@@ -489,7 +519,12 @@
|
||||
notyf('请选择收款用户', 'danger');
|
||||
return;
|
||||
}
|
||||
if (!(parseFloat(amount) > 0) || (type === 'points' && (!Number.isInteger(Number(amount)) || Number(amount) < pointsTransferMin))) {
|
||||
var pointsAmount = /^[1-9]\d*$/.test(String(amount)) ? BigInt(String(amount)) : 0n;
|
||||
var cashAmount = balanceMinor(amount);
|
||||
var validAmount = type === 'points'
|
||||
? pointsAmount >= BigInt(pointsTransferMin) && pointsAmount <= BigInt(Number.MAX_SAFE_INTEGER)
|
||||
: cashAmount !== null && cashAmount > 0n;
|
||||
if (!validAmount) {
|
||||
notyf('请输入有效金额', 'danger');
|
||||
return;
|
||||
}
|
||||
@@ -497,7 +532,12 @@
|
||||
notyf('正在转账…', 'load', '', 'aksrt-balance-transfer-toast');
|
||||
api('/pay/transfer', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type: type, recipient: recipient, amount: amount })
|
||||
headers: { 'Idempotency-Key': operationKey(form) },
|
||||
body: JSON.stringify({
|
||||
type: type,
|
||||
recipient: recipient,
|
||||
amount: type === 'points' ? Number(pointsAmount) : String(amount)
|
||||
})
|
||||
}).then(function (r) {
|
||||
busy = false;
|
||||
if (!r.ok || (r.json && r.json.code && !r.json.success)) {
|
||||
@@ -505,6 +545,7 @@
|
||||
return;
|
||||
}
|
||||
var data = (r.json && r.json.data) ? r.json.data : r.json;
|
||||
resetOperationKey(form);
|
||||
notyf((data && data.message) || '转账成功,正在刷新…', 'success', 5, 'aksrt-balance-transfer-toast');
|
||||
window.setTimeout(function () { window.location.reload(); }, 800);
|
||||
}).catch(function () {
|
||||
@@ -529,12 +570,15 @@
|
||||
btn.addEventListener('click', function () { closeModal(modal); });
|
||||
});
|
||||
if (!form) return;
|
||||
resetOperationKeyOnChange(form);
|
||||
function updatePreview() {
|
||||
if (!preview || !amountEl) return;
|
||||
var amount = Number(amountEl.value || '0');
|
||||
var rate = Number(amountEl.getAttribute('data-aksrt-points-exchange-rate') || '0');
|
||||
preview.textContent = Number.isInteger(amount) && amount > 0 && rate > 0
|
||||
? '预计获得 ' + (amount * rate) + ' 积分'
|
||||
var amount = balanceMinor(amountEl.value);
|
||||
var rateRaw = amountEl.getAttribute('data-aksrt-points-exchange-rate') || '';
|
||||
var rate = /^[1-9]\d*$/.test(rateRaw) ? BigInt(rateRaw) : 0n;
|
||||
var scaled = amount !== null ? amount * rate : 0n;
|
||||
preview.textContent = amount !== null && amount > 0n && rate > 0n && scaled % 100n === 0n
|
||||
? '预计获得 ' + String(scaled / 100n) + ' 积分'
|
||||
: '';
|
||||
}
|
||||
amountEl.addEventListener('input', updatePreview);
|
||||
@@ -542,15 +586,16 @@
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
var amount = amountEl ? Number(amountEl.value || '0') : 0;
|
||||
if (!Number.isInteger(amount) || !(amount > 0)) {
|
||||
notyf('请输入正整数余额', 'danger');
|
||||
var amount = amountEl ? String(amountEl.value || '').trim() : '';
|
||||
if (!/^(0|[1-9]\d*)(?:\.\d{1,2})?$/.test(amount) || /^0(?:\.0{1,2})?$/.test(amount)) {
|
||||
notyf('请输入最多两位小数的正数余额', 'danger');
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
notyf('正在购买积分…', 'load', '', 'aksrt-balance-points-toast');
|
||||
api('/pay/balance/points-exchange', {
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': operationKey(form) },
|
||||
body: JSON.stringify({ amount: amount })
|
||||
}).then(function (r) {
|
||||
busy = false;
|
||||
@@ -559,6 +604,7 @@
|
||||
return;
|
||||
}
|
||||
var data = (r.json && r.json.data) ? r.json.data : r.json;
|
||||
resetOperationKey(form);
|
||||
notyf((data && data.message) || '购买成功,正在刷新…', 'success', 5, 'aksrt-balance-points-toast');
|
||||
window.setTimeout(function () { window.location.reload(); }, 800);
|
||||
}).catch(function () {
|
||||
@@ -581,6 +627,7 @@
|
||||
btn.addEventListener('click', function () { closeModal(modal); });
|
||||
});
|
||||
if (!form) return;
|
||||
resetOperationKeyOnChange(form);
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
@@ -588,17 +635,18 @@
|
||||
var methodEl = form.querySelector('[data-aksrt-withdraw-method]');
|
||||
var accountEl = form.querySelector('[data-aksrt-withdraw-account]');
|
||||
var rawAmount = amountEl ? String(amountEl.value || '').trim() : '';
|
||||
var amount = rawAmount ? Number(rawAmount) : 0;
|
||||
var minAmount = amountEl ? Number(amountEl.getAttribute('data-withdraw-min') || '0') : 0;
|
||||
var remainingAmount = amountEl ? Number(amountEl.getAttribute('data-withdraw-remaining-amount') || '0') : 0;
|
||||
var amount = rawAmount;
|
||||
var amountMinor = balanceMinor(amount);
|
||||
var minAmount = amountEl ? balanceMinor(amountEl.getAttribute('data-withdraw-min') || '0') : 0n;
|
||||
var remainingAmount = amountEl ? balanceMinor(amountEl.getAttribute('data-withdraw-remaining-amount') || '0') : 0n;
|
||||
var remainingCount = amountEl ? Number(amountEl.getAttribute('data-withdraw-remaining-count') || '0') : 0;
|
||||
var method = methodEl ? methodEl.value : 'alipay';
|
||||
var account = accountEl ? String(accountEl.value || '').trim() : '';
|
||||
if (!Number.isInteger(amount) || !(amount > 0)) {
|
||||
notyf('提现金额必须为正整数', 'danger');
|
||||
if (amountMinor === null || amountMinor <= 0n) {
|
||||
notyf('提现金额必须为最多两位小数的正数', 'danger');
|
||||
return;
|
||||
}
|
||||
if (amount < minAmount) {
|
||||
if (minAmount === null || amountMinor < minAmount) {
|
||||
notyf('提现金额低于最低提现门槛', 'danger');
|
||||
return;
|
||||
}
|
||||
@@ -606,7 +654,7 @@
|
||||
notyf('当日提现次数已用尽', 'danger');
|
||||
return;
|
||||
}
|
||||
if (amount > remainingAmount) {
|
||||
if (remainingAmount === null || amountMinor > remainingAmount) {
|
||||
notyf('本次提现后将超出单日提现总额上限', 'danger');
|
||||
return;
|
||||
}
|
||||
@@ -618,6 +666,7 @@
|
||||
notyf('正在提交提现申请…', 'load', '', 'aksrt-balance-withdraw-toast');
|
||||
api('/earnings/withdrawals', {
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': operationKey(form) },
|
||||
body: JSON.stringify({ amount: amount, method: method, account: account })
|
||||
}).then(function (r) {
|
||||
busy = false;
|
||||
@@ -626,6 +675,7 @@
|
||||
return;
|
||||
}
|
||||
var data = (r.json && r.json.data) ? r.json.data : r.json;
|
||||
resetOperationKey(form);
|
||||
notyf((data && data.message) || '申请已提交', 'success', 5, 'aksrt-balance-withdraw-toast');
|
||||
closeModal(modal);
|
||||
var panel = modal.closest ? modal.closest('.dashboard-panel') : null;
|
||||
|
||||
+33
-16
@@ -8,9 +8,10 @@
|
||||
|
||||
var instances = new WeakMap();
|
||||
|
||||
function request(path, body, method) {
|
||||
function request(path, body, method, operationKey) {
|
||||
var options = { method: method || "POST" };
|
||||
if (body !== undefined) options.body = JSON.stringify(body || {});
|
||||
if (operationKey) options.headers = { "Idempotency-Key": operationKey };
|
||||
return window.AksrtRequest.request(path, options);
|
||||
}
|
||||
|
||||
@@ -30,12 +31,15 @@
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function formatMoney(order) {
|
||||
var amount = Number(order && order.amount || 0);
|
||||
return order && order.is_points
|
||||
? escapeHtml(order.currency_symbol || "积分") + " " + Math.round(amount)
|
||||
: "¥" + amount.toFixed(2);
|
||||
}
|
||||
function formatMoney(order) {
|
||||
var raw = String(order && order.amount != null ? order.amount : "0");
|
||||
if (order && order.is_points) {
|
||||
return escapeHtml(order.currency_symbol || "积分") + " " + (/^\d+$/.test(raw) ? raw : "0");
|
||||
}
|
||||
var match = /^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/.exec(raw);
|
||||
var amount = match ? match[1] + "." + (match[2] || "").padEnd(2, "0") : "0.00";
|
||||
return "¥" + amount;
|
||||
}
|
||||
|
||||
function orderTag(orderNumber) {
|
||||
return "order:" + String(orderNumber || "").toLowerCase();
|
||||
@@ -100,9 +104,12 @@
|
||||
}
|
||||
|
||||
function detailOptions(state, orderNumber, refresh) {
|
||||
var actionKey = refresh ? "refresh:" + orderNumber : "";
|
||||
if (actionKey && !state.operationKeys[actionKey]) state.operationKeys[actionKey] = window.AksrtRequest.createIdempotencyKey();
|
||||
return {
|
||||
url: orderPath(orderNumber, refresh ? "/refresh" : ""),
|
||||
method: refresh ? "POST" : "GET",
|
||||
headers: actionKey ? { "Idempotency-Key": state.operationKeys[actionKey] } : {},
|
||||
kind: "orders",
|
||||
title: "订单详情",
|
||||
responseType: "json",
|
||||
@@ -123,7 +130,10 @@
|
||||
renderDetail(state, data, session);
|
||||
},
|
||||
onData: function (data) {
|
||||
if (refresh) invalidateOrder(orderNumber, data);
|
||||
if (refresh) {
|
||||
delete state.operationKeys[actionKey];
|
||||
invalidateOrder(orderNumber, data);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -244,12 +254,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
function startPayment(state, button) {
|
||||
function startPayment(state, button) {
|
||||
var number = button.getAttribute("data-aksrt-order-pay") || "";
|
||||
var method = button.getAttribute("data-aksrt-order-method") || "";
|
||||
if (!number || button.disabled) return;
|
||||
button.disabled = true;
|
||||
request(orderPath(number, "/pay"), method ? { payment_method: method } : {}, "POST").then(function (result) {
|
||||
if (!number || button.disabled) return;
|
||||
var actionKey = "pay:" + number;
|
||||
if (!state.operationKeys[actionKey]) state.operationKeys[actionKey] = window.AksrtRequest.createIdempotencyKey();
|
||||
button.disabled = true;
|
||||
request(orderPath(number, "/pay"), method ? { payment_method: method } : {}, "POST", state.operationKeys[actionKey]).then(function (result) {
|
||||
delete state.operationKeys[actionKey];
|
||||
var data = result.data || {};
|
||||
invalidateOrder(number, data.order);
|
||||
if (data.redirect && data.payment_url) {
|
||||
@@ -273,8 +286,11 @@
|
||||
button.textContent = "确认关闭";
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
request(orderPath(number, "/cancel"), {}, "POST").then(function (result) {
|
||||
button.disabled = true;
|
||||
var actionKey = "cancel:" + number;
|
||||
if (!state.operationKeys[actionKey]) state.operationKeys[actionKey] = window.AksrtRequest.createIdempotencyKey();
|
||||
request(orderPath(number, "/cancel"), {}, "POST", state.operationKeys[actionKey]).then(function (result) {
|
||||
delete state.operationKeys[actionKey];
|
||||
var data = result.data || {};
|
||||
invalidateOrder(number, data.order);
|
||||
notyf("订单已关闭", "success");
|
||||
@@ -294,13 +310,14 @@
|
||||
|
||||
function initRoot(root) {
|
||||
if (instances.has(root)) return;
|
||||
var state = {
|
||||
var state = {
|
||||
root: root,
|
||||
detailOrderNumber: "",
|
||||
detailSession: null,
|
||||
detailReplacement: false,
|
||||
detailReplacementSession: null,
|
||||
countdownTimer: null,
|
||||
countdownTimer: null,
|
||||
operationKeys: {},
|
||||
};
|
||||
|
||||
state.onRootClick = function (event) {
|
||||
|
||||
+17
-1
@@ -11,6 +11,13 @@
|
||||
function initReward() {
|
||||
var activeTrigger = null;
|
||||
|
||||
function operationKey(modal) {
|
||||
if (!modal.dataset.aksrtOperationKey) {
|
||||
modal.dataset.aksrtOperationKey = window.AksrtRequest.createIdempotencyKey();
|
||||
}
|
||||
return modal.dataset.aksrtOperationKey;
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
document.querySelectorAll('.reward-modal.is-open').forEach(function (modal) {
|
||||
modal.classList.remove('is-open');
|
||||
@@ -52,6 +59,7 @@
|
||||
var amountButton = event.target.closest('[data-reward-amount]');
|
||||
if (amountButton) {
|
||||
var modal = amountButton.closest('.reward-modal');
|
||||
delete modal.dataset.aksrtOperationKey;
|
||||
modal.querySelectorAll('[data-reward-amount]').forEach(function (button) {
|
||||
button.classList.toggle('is-active', button === amountButton);
|
||||
});
|
||||
@@ -67,6 +75,10 @@
|
||||
var customInput = tipModal.querySelector('[data-aksrt-reward-custom]');
|
||||
var selected = tipModal.querySelector('[data-reward-amount].is-active');
|
||||
var amount = customInput && customInput.value ? customInput.value : (selected ? selected.dataset.rewardAmount : '');
|
||||
if (customInput && customInput.dataset.aksrtIdempotencyBound !== '1') {
|
||||
customInput.dataset.aksrtIdempotencyBound = '1';
|
||||
customInput.addEventListener('input', function () { delete tipModal.dataset.aksrtOperationKey; });
|
||||
}
|
||||
|
||||
submit.disabled = true;
|
||||
submit.textContent = '赞赏中…';
|
||||
@@ -82,13 +94,17 @@
|
||||
fetch(cfg.ajaxUrl || '/wp-admin/admin-ajax.php', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
'Idempotency-Key': operationKey(tipModal)
|
||||
},
|
||||
body: body.toString()
|
||||
}).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (response) {
|
||||
var text = response && response.data && response.data.message ? response.data.message : '赞赏失败';
|
||||
if (response && response.success) {
|
||||
delete tipModal.dataset.aksrtOperationKey;
|
||||
if (message) message.textContent = text;
|
||||
setTimeout(closeAll, 900);
|
||||
} else if (message) {
|
||||
|
||||
@@ -75,6 +75,11 @@
|
||||
var refreshTimeout = null;
|
||||
var refreshInFlight = false;
|
||||
var openSequence = 0;
|
||||
var pendingCreateKey = '';
|
||||
var pendingCreateSignature = '';
|
||||
var pendingRefreshKey = '';
|
||||
var pendingManualKey = '';
|
||||
var pendingManualSignature = '';
|
||||
|
||||
function isOpen(shell) {
|
||||
return !!shell && shell.classList.contains('is-open') && document.contains(shell);
|
||||
@@ -176,6 +181,7 @@
|
||||
var timedOut = false;
|
||||
var shell = activeShell;
|
||||
var timeoutMs = Math.max(5000, Number(config.refreshTimeout || 15000));
|
||||
if (!pendingRefreshKey) pendingRefreshKey = uuid();
|
||||
refreshTimeout = window.setTimeout(function () {
|
||||
timedOut = true;
|
||||
if (refreshController) {
|
||||
@@ -185,8 +191,10 @@
|
||||
|
||||
return request(config.restBase + '/' + encodeURIComponent(currentUuid) + '/refresh', config, {
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': pendingRefreshKey },
|
||||
signal: refreshController ? refreshController.signal : undefined,
|
||||
}).then(function (data) {
|
||||
pendingRefreshKey = '';
|
||||
if (shell !== activeShell || !isOpen(shell)) return data;
|
||||
pollBackoff = explicit ? 0 : Math.min(4, pollBackoff + 1);
|
||||
render(data, shell);
|
||||
@@ -270,17 +278,23 @@
|
||||
var payload = {};
|
||||
form.forEach(function (value, key) { payload[key] = value; });
|
||||
payload.consent = payload.consent === '1';
|
||||
payload.client_request_id = uuid();
|
||||
var signature = JSON.stringify(payload);
|
||||
if (!pendingCreateKey || pendingCreateSignature !== signature) {
|
||||
pendingCreateKey = uuid();
|
||||
pendingCreateSignature = signature;
|
||||
}
|
||||
if (button) button.disabled = true;
|
||||
if (message) message.textContent = '';
|
||||
aliyunMetaInfo(config).then(function (metaInfo) {
|
||||
if (metaInfo) payload.meta_info = metaInfo;
|
||||
return request(config.restBase, config, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': pendingCreateKey },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}).then(function (data) {
|
||||
pendingCreateKey = '';
|
||||
pendingCreateSignature = '';
|
||||
render(data, shell);
|
||||
}).catch(function (error) {
|
||||
if (message) message.textContent = errorMessage(error, '创建认证会话失败,请重试。');
|
||||
@@ -300,11 +314,21 @@
|
||||
var button = manualForm.querySelector('[type="submit"]');
|
||||
var body = new FormData();
|
||||
Array.prototype.forEach.call((files && files.files) || [], function (file) { body.append('evidence[]', file); });
|
||||
var signature = Array.prototype.map.call((files && files.files) || [], function (file) {
|
||||
return [file.name, file.size, file.lastModified].join(':');
|
||||
}).join('|');
|
||||
if (!pendingManualKey || pendingManualSignature !== signature) {
|
||||
pendingManualKey = uuid();
|
||||
pendingManualSignature = signature;
|
||||
}
|
||||
if (button) button.disabled = true;
|
||||
request(config.restBase + '/' + encodeURIComponent(currentUuid) + '/manual', config, {
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': pendingManualKey },
|
||||
body: body,
|
||||
}).then(function (data) {
|
||||
pendingManualKey = '';
|
||||
pendingManualSignature = '';
|
||||
manualForm.hidden = true;
|
||||
render(data, shell);
|
||||
}).catch(function (error) {
|
||||
|
||||
@@ -89,10 +89,6 @@
|
||||
$(this).text(aksrt__('admin_menu_title_income'));
|
||||
}
|
||||
|
||||
if (url.endsWith('aksrtpay_rebate_page')) {
|
||||
$(this).text(aksrt__('admin_menu_title_card'));
|
||||
}
|
||||
|
||||
if (url.endsWith('aksrtpay_product_page')) {
|
||||
$(this).text(aksrt__('admin_menu_title_product'));
|
||||
}
|
||||
@@ -448,6 +444,6 @@
|
||||
$('.permalink-structure [name="permalink_structure"]').val('/%post_id%.html').click();
|
||||
});
|
||||
|
||||
console.log('\n' + ' %c legacy Theme %c https://legacy.com ' + '\n', 'color: #fadfa3; background: #030307; padding:3px; font-size:12px;', 'color: #2abd4e;background: #16171a; padding: 3px; font-size: 12px;');
|
||||
console.log('%c legacy Theme %c https://legacy.com', 'color: #fadfa3; background: #030307; padding:3px; font-size:12px;', 'color: #2abd4e;background: #16171a; padding: 3px; font-size: 12px;');
|
||||
});
|
||||
})(jQuery, document);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -15,6 +15,7 @@
|
||||
var DEFAULT_MAX_ELAPSED = 120000;
|
||||
var MAX_DELAY = 15000;
|
||||
var createFlights = Object.create(null);
|
||||
var operationKeys = Object.create(null);
|
||||
var polls = Object.create(null);
|
||||
var lastScope = "";
|
||||
var scopeGeneration = 0;
|
||||
@@ -47,6 +48,7 @@
|
||||
if (flight && typeof flight.cancel === "function") flight.cancel();
|
||||
});
|
||||
createFlights = Object.create(null);
|
||||
operationKeys = Object.create(null);
|
||||
Object.keys(polls).forEach(function (pollKey) {
|
||||
var active = polls[pollKey];
|
||||
if (active && active.handle && typeof active.handle.abort === "function") active.handle.abort();
|
||||
@@ -115,6 +117,7 @@
|
||||
|
||||
var key = userScope() + ":" + String(packageId) + ":" + method;
|
||||
if (createFlights[key]) return createFlights[key].promise;
|
||||
if (!operationKeys[key]) operationKeys[key] = window.AksrtRequest.createIdempotencyKey();
|
||||
|
||||
var requestController = !options.signal && window.AbortController
|
||||
? new window.AbortController()
|
||||
@@ -122,6 +125,7 @@
|
||||
var flight = {
|
||||
promise: request("/pay/vip", {
|
||||
method: "POST",
|
||||
headers: { "Idempotency-Key": operationKeys[key] },
|
||||
body: JSON.stringify({
|
||||
package_id: packageId,
|
||||
payment_method: method,
|
||||
@@ -143,22 +147,31 @@
|
||||
}, function () {
|
||||
if (createFlights[key] === flight) delete createFlights[key];
|
||||
});
|
||||
flight.promise.then(function (response) {
|
||||
if (response.ok) delete operationKeys[key];
|
||||
});
|
||||
return flight.promise;
|
||||
}
|
||||
|
||||
function exchange(options) {
|
||||
options = options || {};
|
||||
userScope();
|
||||
var scope = userScope();
|
||||
var packageId = parseInt(options.packageId || options.package_id || 0, 10);
|
||||
if (!packageId) {
|
||||
return Promise.resolve({ ok: false, status: 400, json: { message: "兑换套餐无效" } });
|
||||
}
|
||||
var key = scope + ":points-exchange:" + String(packageId);
|
||||
if (!operationKeys[key]) operationKeys[key] = window.AksrtRequest.createIdempotencyKey();
|
||||
return request("/pay/vip/points-exchange", {
|
||||
method: "POST",
|
||||
headers: { "Idempotency-Key": operationKeys[key] },
|
||||
body: JSON.stringify({ package_id: packageId }),
|
||||
signal: options.signal,
|
||||
}).then(function (response) {
|
||||
if (response.ok) invalidate(options.tags || ["vip", "orders"]);
|
||||
if (response.ok) {
|
||||
delete operationKeys[key];
|
||||
invalidate(options.tags || ["vip", "orders"]);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,27 +48,26 @@ if (!function_exists('aksrt_burn_order_coupon')) {
|
||||
|
||||
$coupon = is_array($meta['coupon'] ?? null) ? $meta['coupon'] : [];
|
||||
$coupon_code = sanitize_text_field((string) ($coupon['code'] ?? $meta['coupon_code'] ?? ''));
|
||||
$coupon_discount = (float) ($meta['coupon_discount'] ?? $meta['discount'] ?? ($coupon['discount'] ?? 0));
|
||||
$coupon_discount = (string) ($meta['coupon_discount'] ?? $meta['discount'] ?? ($coupon['discount'] ?? '0.00'));
|
||||
if ($coupon_code === '') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$coupon_discount = \AKSRT\Ledger\AssetAmount::balance($coupon_discount)->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$coupon_api = \AKSRT\Payment\Coupon::get_instance();
|
||||
$coupon_api->use_coupon($coupon_code);
|
||||
|
||||
// Prefer snapshot id: post-increment validate may fail on usage_limit edge.
|
||||
$coupon_id = (int) ($coupon['id'] ?? 0);
|
||||
if ($coupon_id <= 0) {
|
||||
$validated = $coupon_api->validate($coupon_code, max(0.01, $coupon_discount), [
|
||||
'user_id' => max(0, $buyer_id),
|
||||
]);
|
||||
$coupon_id = (int) ($validated['coupon']['id'] ?? 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($coupon_id > 0) {
|
||||
$coupon_api->log_usage($coupon_id, max(0, $buyer_id), $order_number, max(0.0, $coupon_discount));
|
||||
if (!$coupon_api->log_usage($coupon_id, max(0, $buyer_id), $order_number, $coupon_discount)) {
|
||||
return aksrt_coupon_usage_exists($order_number);
|
||||
}
|
||||
|
||||
return true;
|
||||
return $coupon_api->use_coupon($coupon_code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +1,38 @@
|
||||
<?php
|
||||
/**
|
||||
* AKSRT Credits Helper
|
||||
*
|
||||
* 模板层使用的顶层辅助函数:
|
||||
* aksrt_user_credits($user_id = 0) 读取余额
|
||||
* aksrt_format_credits(int $n) 格式化展示
|
||||
* aksrt_credits_label(string $reason) 流水原因 → 友好文案
|
||||
* Unified asset presentation helpers.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
if (!function_exists('aksrt_user_credits')) {
|
||||
/**
|
||||
* 取用户当前积分余额。
|
||||
*
|
||||
* @param int $user_id 0 表示当前登录用户
|
||||
* @return int
|
||||
*/
|
||||
function aksrt_user_credits(int $user_id = 0): int {
|
||||
if ($user_id <= 0) $user_id = get_current_user_id();
|
||||
if ($user_id <= 0) return 0;
|
||||
if (!class_exists('\AKSRT\Credits\Credits')) return 0;
|
||||
return \AKSRT\Credits\Credits::balance($user_id);
|
||||
}
|
||||
if (!function_exists('aksrt_user_points')) {
|
||||
function aksrt_user_points(int $user_id = 0): int {
|
||||
$user_id = $user_id > 0 ? $user_id : get_current_user_id();
|
||||
return $user_id > 0
|
||||
? \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS)
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('aksrt_format_credits')) {
|
||||
function aksrt_format_credits(int $n): string {
|
||||
return number_format_i18n($n);
|
||||
}
|
||||
if (!function_exists('aksrt_user_balance_decimal')) {
|
||||
function aksrt_user_balance_decimal(int $user_id = 0): string {
|
||||
$user_id = $user_id > 0 ? $user_id : get_current_user_id();
|
||||
return $user_id > 0
|
||||
? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id)
|
||||
: '0.00';
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('aksrt_credits_label')) {
|
||||
/**
|
||||
* 把 reason 字段翻译成展示文案。
|
||||
*/
|
||||
function aksrt_credits_label(string $reason): string {
|
||||
$map = [
|
||||
'register' => __('注册赠送', 'aksrt'),
|
||||
'signin' => __('每日签到', 'aksrt'),
|
||||
'comment' => __('发表评论', 'aksrt'),
|
||||
'referral' => __('推广注册奖励', 'aksrt'),
|
||||
'referral_new_user' => __('推广注册新用户奖励', 'aksrt'),
|
||||
'download' => __('下载消费', 'aksrt'),
|
||||
'admin' => __('管理员调整', 'aksrt'),
|
||||
'refund' => __('退款', 'aksrt'),
|
||||
'free_sign_up' => __('注册奖励', 'aksrt'),
|
||||
'free_sign_in' => __('每日签到', 'aksrt'),
|
||||
'free_login' => __('每日登录', 'aksrt'),
|
||||
'free_followed'=> __('被关注', 'aksrt'),
|
||||
'free_post_new'=> __('发布文章', 'aksrt'),
|
||||
'free_post_like'=> __('文章被赞', 'aksrt'),
|
||||
'free_post_favorite'=> __('文章被收藏', 'aksrt'),
|
||||
'free_comment_new'=> __('发表评论', 'aksrt'),
|
||||
'free_comment_like'=> __('评论被赞', 'aksrt'),
|
||||
'points_exchange_in' => __('余额兑换积分', 'aksrt'),
|
||||
'transfer_in' => __('积分转入', 'aksrt'),
|
||||
'transfer_out' => __('积分转出', 'aksrt'),
|
||||
'transfer_refund' => __('积分转账退款', 'aksrt'),
|
||||
];
|
||||
if (isset($map[$reason])) {
|
||||
return $map[$reason];
|
||||
}
|
||||
if (str_starts_with($reason, 'free_') && class_exists('\AKSRT\Credits\PointsFree')) {
|
||||
$key = substr($reason, 5);
|
||||
$cat = \AKSRT\Credits\PointsFree::catalog();
|
||||
if (isset($cat[$key][0])) {
|
||||
return (string) $cat[$key][0];
|
||||
}
|
||||
}
|
||||
return $reason;
|
||||
}
|
||||
if (!function_exists('aksrt_format_points')) {
|
||||
function aksrt_format_points(int $points): string {
|
||||
return number_format_i18n($points);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('aksrt_asset_reason_label')) {
|
||||
function aksrt_asset_reason_label(string $reason): string {
|
||||
return \AKSRT\Ledger\LedgerPresentation::reason_label($reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,10 +89,10 @@ if (!function_exists('aksrt_resolve_single_post_display_settings')) :
|
||||
'rewards_enabled' => false,
|
||||
'rewards_text' => '',
|
||||
'rewards_btn_text' => '赞赏',
|
||||
'rewards_amounts' => [5, 10, 20, 50, 100],
|
||||
'rewards_amounts' => ['5.00', '10.00', '20.00', '50.00', '100.00'],
|
||||
'rewards_custom' => true,
|
||||
'rewards_min' => 1,
|
||||
'rewards_max' => 1000,
|
||||
'rewards_min' => '1.00',
|
||||
'rewards_max' => '1000.00',
|
||||
'maxheight_enabled' => false,
|
||||
'maxheight' => 1000,
|
||||
'copyright_enabled' => true,
|
||||
|
||||
@@ -77,14 +77,11 @@ if (!function_exists('aksrt_header_user_wallet_mini')) :
|
||||
if ($user_id <= 0) {
|
||||
return '';
|
||||
}
|
||||
$credits = function_exists('aksrt_user_credits') ? (int) aksrt_user_credits($user_id) : 0;
|
||||
$credits = aksrt_user_points($user_id);
|
||||
$currency = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_currency_name()
|
||||
: __('积分', 'aksrt');
|
||||
$wallet = 0.0;
|
||||
if (class_exists('\AKSRT\Credits\Balance')) {
|
||||
$wallet = (float) \AKSRT\Credits\Balance::balance($user_id);
|
||||
}
|
||||
$wallet = aksrt_user_balance_decimal($user_id);
|
||||
$center = function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url('credits') : '#';
|
||||
|
||||
$html = '<a class="aksrt-header-wallet flex ac jsb" href="' . esc_url($center) . '">';
|
||||
|
||||
+131
-57
@@ -35,6 +35,30 @@ if (!defined('AKSRT_PAY_TYPE_VIP')) {
|
||||
define('AKSRT_PAY_TYPE_VIP', \AKSRT\Core\DomainModel::PAY_TYPE_VIP);
|
||||
}
|
||||
|
||||
if (!function_exists('aksrt_asset_price_minor')) {
|
||||
function aksrt_asset_price_minor($value, string $pay_modo): int {
|
||||
if ($pay_modo === 'points') {
|
||||
return is_int($value) ? max(0, $value) : 0;
|
||||
}
|
||||
if (!is_string($value)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance_minor($value);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('aksrt_asset_price_value')) {
|
||||
function aksrt_asset_price_value(int $minor, string $pay_modo) {
|
||||
return $pay_modo === 'points'
|
||||
? max(0, $minor)
|
||||
: \AKSRT\Ledger\AssetAmount::decimal_from_minor(max(0, $minor));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否为 VIP 豁免用户(免扣积分下载)。
|
||||
*
|
||||
@@ -195,18 +219,18 @@ if (!function_exists('aksrt_get_download_box_meta')) {
|
||||
if (!in_array($pay_limit, [0, 1, 2], true)) {
|
||||
$pay_limit = 0;
|
||||
}
|
||||
$over_price = max(0.0, (float) ($pay_meta['download_limit_over_price'] ?? 0));
|
||||
$over_price = $pay_meta['download_limit_over_price'] ?? ($pay_modo === 'points' ? 0 : '0.00');
|
||||
|
||||
return [
|
||||
'version' => (string) ($pay_meta['version'] ?? ''),
|
||||
'size' => (string) ($pay_meta['size'] ?? ''),
|
||||
'price' => (float) ($pay_meta['price'] ?? 0),
|
||||
'price' => (string) ($pay_meta['price'] ?? '0.00'),
|
||||
'points_price' => (int) ($pay_meta['points_price'] ?? 0),
|
||||
'pay_modo' => $pay_modo,
|
||||
'pay_limit' => $pay_limit,
|
||||
'original_price' => (float) ($pay_meta['original_price'] ?? 0),
|
||||
'vip1_price' => (float) ($pay_meta['vip1_price'] ?? 0),
|
||||
'vip2_price' => (float) ($pay_meta['vip2_price'] ?? 0),
|
||||
'original_price' => (string) ($pay_meta['original_price'] ?? '0.00'),
|
||||
'vip1_price' => (string) ($pay_meta['vip1_price'] ?? '0.00'),
|
||||
'vip2_price' => (string) ($pay_meta['vip2_price'] ?? '0.00'),
|
||||
'vip1_points' => (int) ($pay_meta['vip1_points'] ?? 0),
|
||||
'vip2_points' => (int) ($pay_meta['vip2_points'] ?? 0),
|
||||
'pay_type' => (string) ($pay_meta['pay_type'] ?? AKSRT_PAY_TYPE_NO),
|
||||
@@ -349,8 +373,8 @@ if (!function_exists('aksrt_content_order_cookie_token_key')) {
|
||||
$post_id = (int) ($order['product_id'] ?? $meta['post_id'] ?? 0);
|
||||
$num = (string) ($order['order_number'] ?? '');
|
||||
$amount = function_exists('aksrt_format_price')
|
||||
? aksrt_format_price((float) ($order['amount'] ?? 0))
|
||||
: (string) round((float) ($order['amount'] ?? 0), 2);
|
||||
? aksrt_format_price((string) ($order['amount'] ?? '0.00'))
|
||||
: (string) ($order['amount'] ?? '0.00');
|
||||
return $id . '_' . $post_id . '_' . trim($num) . '_' . $amount . '_' . trim($ip);
|
||||
}
|
||||
}
|
||||
@@ -484,7 +508,7 @@ if (!function_exists('aksrt_guest_has_paid_access')) {
|
||||
*
|
||||
* @param int|WP_Post|null $post
|
||||
* @param int $user_id
|
||||
* @return array{amount:float,base:float,vip_amount:float,modo:string,currency:string,is_free:bool,pay_limit:int,vip_level:int,original:float,vip1:float,vip2:float}
|
||||
* @return array{amount:int|string,base:int|string,vip_amount:int|string|null,modo:string,currency:string,is_free:bool,pay_limit:int,vip_level:int,original:int|string,vip1:int|string,vip2:int|string}
|
||||
*/
|
||||
if (!function_exists('aksrt_get_user_pay_price')) {
|
||||
function aksrt_get_user_pay_price($post = null, int $user_id = 0): array {
|
||||
@@ -512,49 +536,74 @@ if (!function_exists('aksrt_get_user_pay_price')) {
|
||||
}
|
||||
|
||||
if ($pay_modo === 'points') {
|
||||
$base = (float) ($meta['points_price'] ?? 0);
|
||||
$vip1 = (float) ($meta['vip1_points'] ?? 0);
|
||||
$vip2 = (float) ($meta['vip2_points'] ?? 0);
|
||||
$base_minor = max(0, (int) ($meta['points_price'] ?? 0));
|
||||
$vip1_minor = max(0, (int) ($meta['vip1_points'] ?? 0));
|
||||
$vip2_minor = max(0, (int) ($meta['vip2_points'] ?? 0));
|
||||
$currency_label = $currency;
|
||||
} else {
|
||||
$base = (float) ($meta['price'] ?? 0);
|
||||
$vip1 = (float) ($meta['vip1_price'] ?? 0);
|
||||
$vip2 = (float) ($meta['vip2_price'] ?? 0);
|
||||
$parse_cash = static function ($value): int {
|
||||
if (!is_string($value)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance_minor($value);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
$base_minor = $parse_cash($meta['price'] ?? '0');
|
||||
$vip1_minor = $parse_cash($meta['vip1_price'] ?? '0');
|
||||
$vip2_minor = $parse_cash($meta['vip2_price'] ?? '0');
|
||||
$currency_label = $cash_symbol;
|
||||
}
|
||||
|
||||
// Positive VIP tier prices only (0 defaults mean "not configured").
|
||||
$vip_amount = -1.0;
|
||||
if ($vip_level >= 2 && $vip2 > 0) {
|
||||
$vip_amount = $vip2;
|
||||
} elseif ($vip_level >= 1 && $vip1 > 0) {
|
||||
$vip_amount = $vip1;
|
||||
$vip_minor = null;
|
||||
if ($vip_level >= 2 && $vip2_minor > 0) {
|
||||
$vip_minor = $vip2_minor;
|
||||
} elseif ($vip_level >= 1 && $vip1_minor > 0) {
|
||||
$vip_minor = $vip1_minor;
|
||||
}
|
||||
|
||||
$amount = $base;
|
||||
if ($vip_amount >= 0 && ($base <= 0 || $vip_amount < $base)) {
|
||||
$amount = $vip_amount;
|
||||
$amount_minor = $base_minor;
|
||||
if ($vip_minor !== null && ($base_minor <= 0 || $vip_minor < $base_minor)) {
|
||||
$amount_minor = $vip_minor;
|
||||
}
|
||||
|
||||
// Role/VIP free-download exemption (global setting).
|
||||
if ($amount > 0 && $uid > 0 && function_exists('aksrt_is_download_credit_exempt_user') && aksrt_is_download_credit_exempt_user($uid)) {
|
||||
$amount = 0.0;
|
||||
if ($amount_minor > 0 && $uid > 0 && function_exists('aksrt_is_download_credit_exempt_user') && aksrt_is_download_credit_exempt_user($uid)) {
|
||||
$amount_minor = 0;
|
||||
}
|
||||
|
||||
$original = (float) ($meta['original_price'] ?? 0);
|
||||
if ($pay_modo === 'points') {
|
||||
$original = max(0, (int) ($meta['original_price'] ?? 0));
|
||||
$amount = $amount_minor;
|
||||
$base = $base_minor;
|
||||
$vip1 = $vip1_minor;
|
||||
$vip2 = $vip2_minor;
|
||||
$vip_amount = $vip_minor;
|
||||
} else {
|
||||
$original_minor = $parse_cash($meta['original_price'] ?? '0');
|
||||
$amount = \AKSRT\Ledger\AssetAmount::decimal_from_minor($amount_minor);
|
||||
$base = \AKSRT\Ledger\AssetAmount::decimal_from_minor($base_minor);
|
||||
$vip1 = \AKSRT\Ledger\AssetAmount::decimal_from_minor($vip1_minor);
|
||||
$vip2 = \AKSRT\Ledger\AssetAmount::decimal_from_minor($vip2_minor);
|
||||
$vip_amount = $vip_minor === null ? null : \AKSRT\Ledger\AssetAmount::decimal_from_minor($vip_minor);
|
||||
$original = \AKSRT\Ledger\AssetAmount::decimal_from_minor($original_minor);
|
||||
}
|
||||
|
||||
return [
|
||||
'amount' => max(0.0, (float) $amount),
|
||||
'base' => max(0.0, (float) $base),
|
||||
'vip_amount' => $vip_amount >= 0 ? max(0.0, (float) $vip_amount) : -1.0,
|
||||
'amount' => $amount,
|
||||
'base' => $base,
|
||||
'vip_amount' => $vip_amount,
|
||||
'modo' => $pay_modo,
|
||||
'currency' => $currency_label,
|
||||
'is_free' => $amount <= 0,
|
||||
'is_free' => $amount_minor <= 0,
|
||||
'pay_limit' => $pay_limit,
|
||||
'vip_level' => $vip_level,
|
||||
'original' => $original,
|
||||
'vip1' => max(0.0, (float) $vip1),
|
||||
'vip2' => max(0.0, (float) $vip2),
|
||||
'vip1' => $vip1,
|
||||
'vip2' => $vip2,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -585,32 +634,33 @@ if (!function_exists('aksrt_is_content_coupon_allowed')) {
|
||||
* Cash only; mirrors aksrtpay_get_post_rebate_discount.
|
||||
*/
|
||||
if (!function_exists('aksrt_get_post_rebate_discount')) {
|
||||
function aksrt_get_post_rebate_discount($post = null, int $user_id = 0): float {
|
||||
function aksrt_get_post_rebate_discount($post = null, int $user_id = 0): string {
|
||||
$meta = function_exists('aksrt_get_pay_meta') ? aksrt_get_pay_meta($post) : [];
|
||||
$fixed = max(0.0, (float) ($meta['pay_rebate_discount'] ?? 0));
|
||||
if ($fixed <= 0) {
|
||||
return 0.0;
|
||||
$fixed = is_string($meta['pay_rebate_discount'] ?? null) ? (string) $meta['pay_rebate_discount'] : '0.00';
|
||||
$fixed_minor = aksrt_asset_price_minor($fixed, '0');
|
||||
if ($fixed_minor <= 0) {
|
||||
return '0.00';
|
||||
}
|
||||
if (!class_exists('\AKSRT\Settings\Settings') || !method_exists('\AKSRT\Settings\Settings', 'get_payment_affiliate_settings')) {
|
||||
return 0.0;
|
||||
return '0.00';
|
||||
}
|
||||
$aff = \AKSRT\Settings\Settings::get_payment_affiliate_settings();
|
||||
if (empty($aff['enabled'])) {
|
||||
return 0.0;
|
||||
return '0.00';
|
||||
}
|
||||
$uid = $user_id > 0 ? $user_id : (function_exists('get_current_user_id') ? (int) get_current_user_id() : 0);
|
||||
$referrer_id = class_exists('\AKSRT\Payment\ReferralAttribution')
|
||||
? \AKSRT\Payment\ReferralAttribution::get_instance()->resolve($uid)
|
||||
: 0;
|
||||
if ($referrer_id <= 0) {
|
||||
return 0.0;
|
||||
return '0.00';
|
||||
}
|
||||
// Need a positive ratio for referrer (VIP tier of referrer preferred).
|
||||
$ratio = (int) round(\AKSRT\Settings\Settings::affiliate_ratio_for_user($referrer_id));
|
||||
$ratio = (int) \AKSRT\Settings\Settings::affiliate_ratio_for_user($referrer_id);
|
||||
if ($ratio <= 0) {
|
||||
return 0.0;
|
||||
return '0.00';
|
||||
}
|
||||
return round($fixed, 2);
|
||||
return \AKSRT\Ledger\AssetAmount::decimal_from_minor($fixed_minor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,10 +752,10 @@ if (!function_exists('aksrt_is_payshow_purchase_enabled')) {
|
||||
|
||||
$pay_modo = ($meta['pay_modo'] ?? '0') === 'points' ? 'points' : '0';
|
||||
$price = $pay_modo === 'points'
|
||||
? (float) ($meta['points_price'] ?? 0)
|
||||
: (float) ($meta['price'] ?? 0);
|
||||
? (int) ($meta['points_price'] ?? 0)
|
||||
: (string) ($meta['price'] ?? '0.00');
|
||||
|
||||
return $price > 0;
|
||||
return aksrt_asset_price_minor($price, $pay_modo) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,7 +801,9 @@ if (!function_exists('aksrt_has_paid_access')) {
|
||||
// 兜底:仅在 service 不可用时使用,避免离线环境直接拒绝。
|
||||
$meta = aksrt_get_pay_meta($p);
|
||||
if ($meta['pay_type'] === AKSRT_PAY_TYPE_NO) return true;
|
||||
if ((float) $meta['price'] <= 0 && $meta['pay_type'] !== AKSRT_PAY_TYPE_VIP) return true;
|
||||
$pay_modo = ($meta['pay_modo'] ?? '0') === 'points' ? 'points' : '0';
|
||||
$price = $pay_modo === 'points' ? (int) ($meta['points_price'] ?? 0) : (string) ($meta['price'] ?? '0.00');
|
||||
if (aksrt_asset_price_minor($price, $pay_modo) <= 0 && $meta['pay_type'] !== AKSRT_PAY_TYPE_VIP) return true;
|
||||
|
||||
$uid = $user_id > 0 ? $user_id : get_current_user_id();
|
||||
|
||||
@@ -843,7 +895,10 @@ if (!function_exists('aksrt_get_download_cost')) {
|
||||
}
|
||||
|
||||
$meta = aksrt_get_pay_meta($post);
|
||||
return (int) ceil((float) $meta['price']);
|
||||
if (($meta['pay_modo'] ?? '0') !== 'points') {
|
||||
return 0;
|
||||
}
|
||||
return max(0, (int) ($meta['points_price'] ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -918,18 +973,19 @@ if (!function_exists('aksrt_get_pay_price_label')) {
|
||||
'text' => __('会员专属', 'aksrt'),
|
||||
'free' => false,
|
||||
'amount' => 0,
|
||||
'base' => is_array($priced) ? (float) ($priced['base'] ?? 0) : 0,
|
||||
'base' => is_array($priced) ? ($priced['base'] ?? ($pay_modo === 'points' ? 0 : '0.00')) : ($pay_modo === 'points' ? 0 : '0.00'),
|
||||
'modo' => $pay_modo,
|
||||
'pay_limit' => $pay_limit,
|
||||
];
|
||||
}
|
||||
|
||||
$price = is_array($priced)
|
||||
? (float) ($priced['amount'] ?? 0)
|
||||
: ($pay_modo === 'points' ? (float) ($meta['points_price'] ?? 0) : (float) ($meta['price'] ?? 0));
|
||||
$base = is_array($priced) ? (float) ($priced['base'] ?? $price) : $price;
|
||||
? ($priced['amount'] ?? ($pay_modo === 'points' ? 0 : '0.00'))
|
||||
: ($pay_modo === 'points' ? (int) ($meta['points_price'] ?? 0) : (string) ($meta['price'] ?? '0.00'));
|
||||
$base = is_array($priced) ? ($priced['base'] ?? $price) : $price;
|
||||
$price_minor = aksrt_asset_price_minor($price, $pay_modo);
|
||||
|
||||
if ($price <= 0) {
|
||||
if ($price_minor <= 0) {
|
||||
return [
|
||||
'text' => __('免费', 'aksrt'),
|
||||
'free' => true,
|
||||
@@ -963,9 +1019,21 @@ if (!function_exists('aksrt_get_pay_price_label')) {
|
||||
}
|
||||
|
||||
if (!function_exists('aksrt_format_price')) {
|
||||
function aksrt_format_price(float $price): string {
|
||||
if (fmod($price, 1.0) === 0.0) return (string) (int) $price;
|
||||
return rtrim(rtrim(number_format($price, 2, '.', ''), '0'), '.');
|
||||
function aksrt_format_price($price): string {
|
||||
if (is_int($price)) {
|
||||
return (string) max(0, $price);
|
||||
}
|
||||
if (!is_string($price)) {
|
||||
return '0';
|
||||
}
|
||||
try {
|
||||
$decimal = \AKSRT\Ledger\AssetAmount::balance($price)->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return '0';
|
||||
}
|
||||
return str_ends_with($decimal, '.00')
|
||||
? substr($decimal, 0, -3)
|
||||
: rtrim($decimal, '0');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1132,7 +1200,10 @@ if (!function_exists('aksrt_get_paywall_html')) {
|
||||
$pay_type = $meta['pay_type'];
|
||||
$priced = function_exists('aksrt_get_user_pay_price') ? aksrt_get_user_pay_price($post) : null;
|
||||
$pay_modo = is_array($priced) ? ((($priced['modo'] ?? '0') === 'points') ? 'points' : '0') : ((($meta['pay_modo'] ?? '0') === 'points') ? 'points' : '0');
|
||||
$price = is_array($priced) ? (float) ($priced['amount'] ?? 0) : (float) ($meta['price'] ?? 0);
|
||||
$price = is_array($priced)
|
||||
? ($priced['amount'] ?? ($pay_modo === 'points' ? 0 : '0.00'))
|
||||
: ($pay_modo === 'points' ? (int) ($meta['points_price'] ?? 0) : (string) ($meta['price'] ?? '0.00'));
|
||||
$price_minor = aksrt_asset_price_minor($price, $pay_modo);
|
||||
$vip_level = is_array($priced) ? (int) ($priced['vip_level'] ?? 0) : 0;
|
||||
$currency = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt');
|
||||
$cash_symbol = '¥';
|
||||
@@ -1156,8 +1227,11 @@ if (!function_exists('aksrt_get_paywall_html')) {
|
||||
}
|
||||
$login_url = function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url(get_permalink($post));
|
||||
$is_logged_in = is_user_logged_in();
|
||||
$original_price = (float) ($meta['original_price'] ?? 0);
|
||||
$has_original = $original_price > 0 && $original_price > $price;
|
||||
$original_price = $pay_modo === 'points'
|
||||
? max(0, (int) ($meta['original_price'] ?? 0))
|
||||
: (string) ($meta['original_price'] ?? '0.00');
|
||||
$original_minor = aksrt_asset_price_minor($original_price, $pay_modo);
|
||||
$has_original = $original_minor > 0 && $original_minor > $price_minor;
|
||||
$pay_limit = (int) ($meta['pay_limit'] ?? 0);
|
||||
$member_blocked = $pay_limit > 0 && $vip_level < $pay_limit;
|
||||
$vip_url = function_exists('aksrt_get_user_center_tab_url')
|
||||
|
||||
@@ -67,13 +67,13 @@ if (!function_exists('aksrt_get_resource_meta')) {
|
||||
return [
|
||||
'version' => (string) ($pay_meta['version'] ?? ''),
|
||||
'size' => (string) ($pay_meta['size'] ?? ''),
|
||||
'price' => (float) ($pay_meta['price'] ?? 0),
|
||||
'price' => (string) ($pay_meta['price'] ?? '0.00'),
|
||||
'points_price' => (int) ($pay_meta['points_price'] ?? 0),
|
||||
'pay_modo' => (($pay_meta['pay_modo'] ?? '0') === 'points') ? 'points' : '0',
|
||||
'pay_limit' => in_array((int) ($pay_meta['pay_limit'] ?? 0), [0, 1, 2], true) ? (int) $pay_meta['pay_limit'] : 0,
|
||||
'original_price' => (float) ($pay_meta['original_price'] ?? 0),
|
||||
'vip1_price' => (float) ($pay_meta['vip1_price'] ?? 0),
|
||||
'vip2_price' => (float) ($pay_meta['vip2_price'] ?? 0),
|
||||
'original_price' => (string) ($pay_meta['original_price'] ?? '0.00'),
|
||||
'vip1_price' => (string) ($pay_meta['vip1_price'] ?? '0.00'),
|
||||
'vip2_price' => (string) ($pay_meta['vip2_price'] ?? '0.00'),
|
||||
'vip1_points' => (int) ($pay_meta['vip1_points'] ?? 0),
|
||||
'vip2_points' => (int) ($pay_meta['vip2_points'] ?? 0),
|
||||
'pay_type' => (string) ($pay_meta['pay_type'] ?? \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD),
|
||||
@@ -99,13 +99,13 @@ if (!function_exists('aksrt_default_resource_meta')) {
|
||||
return [
|
||||
'version' => (string) ($meta['version'] ?? ''),
|
||||
'size' => (string) ($meta['size'] ?? ''),
|
||||
'price' => (float) ($meta['price'] ?? 0),
|
||||
'price' => (string) ($meta['price'] ?? '0.00'),
|
||||
'points_price' => (int) ($meta['points_price'] ?? 0),
|
||||
'pay_modo' => (($meta['pay_modo'] ?? '0') === 'points') ? 'points' : '0',
|
||||
'pay_limit' => in_array((int) ($meta['pay_limit'] ?? 0), [0, 1, 2], true) ? (int) $meta['pay_limit'] : 0,
|
||||
'original_price' => (float) ($meta['original_price'] ?? 0),
|
||||
'vip1_price' => (float) ($meta['vip1_price'] ?? 0),
|
||||
'vip2_price' => (float) ($meta['vip2_price'] ?? 0),
|
||||
'original_price' => (string) ($meta['original_price'] ?? '0.00'),
|
||||
'vip1_price' => (string) ($meta['vip1_price'] ?? '0.00'),
|
||||
'vip2_price' => (string) ($meta['vip2_price'] ?? '0.00'),
|
||||
'vip1_points' => (int) ($meta['vip1_points'] ?? 0),
|
||||
'vip2_points' => (int) ($meta['vip2_points'] ?? 0),
|
||||
'pay_type' => \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD,
|
||||
@@ -128,19 +128,18 @@ if (!function_exists('aksrt_resource_price_label')) {
|
||||
/**
|
||||
* 把价格数字渲染成展示徽章文案。
|
||||
*
|
||||
* @param float $price
|
||||
* @param int $price
|
||||
* @return array { text:string, free:bool }
|
||||
*/
|
||||
function aksrt_resource_price_label(float $price): array {
|
||||
function aksrt_resource_price_label(int $price): array {
|
||||
if ($price <= 0) {
|
||||
return [
|
||||
'text' => __('免费', 'aksrt'),
|
||||
'free' => true,
|
||||
];
|
||||
}
|
||||
$n = (fmod($price, 1.0) === 0.0) ? (string) (int) $price : rtrim(rtrim(number_format($price, 2, '.', ''), '0'), '.');
|
||||
return [
|
||||
'text' => sprintf(__('%s 积分', 'aksrt'), $n),
|
||||
'text' => sprintf(__('%s 积分', 'aksrt'), (string) $price),
|
||||
'free' => false,
|
||||
];
|
||||
}
|
||||
@@ -150,15 +149,11 @@ if (!function_exists('aksrt_resource_price_amount')) {
|
||||
/**
|
||||
* Format the raw resource price number for compact UI labels.
|
||||
*
|
||||
* @param float $price
|
||||
* @param int $price
|
||||
* @return string
|
||||
*/
|
||||
function aksrt_resource_price_amount(float $price): string {
|
||||
if (fmod($price, 1.0) === 0.0) {
|
||||
return (string) (int) $price;
|
||||
}
|
||||
|
||||
return rtrim(rtrim(number_format($price, 2, '.', ''), '0'), '.');
|
||||
function aksrt_resource_price_amount(int $price): string {
|
||||
return (string) max(0, $price);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,14 +196,15 @@ if (!function_exists('aksrt_resource_card_badge_text')) {
|
||||
$priced = function_exists('aksrt_get_user_pay_price')
|
||||
? aksrt_get_user_pay_price($p)
|
||||
: [
|
||||
'amount' => $pay_modo === 'points' ? (float) ($meta['points_price'] ?? 0) : (float) ($meta['price'] ?? 0),
|
||||
'amount' => $pay_modo === 'points' ? (int) ($meta['points_price'] ?? 0) : (string) ($meta['price'] ?? '0.00'),
|
||||
'modo' => $pay_modo,
|
||||
];
|
||||
$price = (float) ($priced['amount'] ?? 0);
|
||||
$price = $priced['amount'] ?? ($pay_modo === 'points' ? 0 : '0.00');
|
||||
$pay_modo = (($priced['modo'] ?? $pay_modo) === 'points') ? 'points' : '0';
|
||||
$price_minor = function_exists('aksrt_asset_price_minor') ? aksrt_asset_price_minor($price, $pay_modo) : 0;
|
||||
|
||||
// Free paid-type (price 0).
|
||||
if ($price <= 0) {
|
||||
if ($price_minor <= 0) {
|
||||
if ($pay_type === 'download') {
|
||||
return __('免费资源', 'aksrt');
|
||||
}
|
||||
@@ -218,7 +214,7 @@ if (!function_exists('aksrt_resource_card_badge_text')) {
|
||||
if ($pay_modo === 'points') {
|
||||
$currency = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt');
|
||||
$amount = function_exists('aksrt_resource_price_amount')
|
||||
? aksrt_resource_price_amount($price)
|
||||
? aksrt_resource_price_amount($price_minor)
|
||||
: (string) (int) $price;
|
||||
return sprintf('%s %s%s', $type_name, $amount, $currency);
|
||||
}
|
||||
@@ -461,13 +457,11 @@ if (!function_exists('aksrt_resource_download_cost')) {
|
||||
}
|
||||
|
||||
if (is_array($priced)) {
|
||||
$price = (float) ($priced['amount'] ?? 0);
|
||||
return $price > 0 ? (int) ceil($price) : 0;
|
||||
return is_int($priced['amount'] ?? null) ? max(0, (int) $priced['amount']) : 0;
|
||||
}
|
||||
|
||||
$meta = function_exists('aksrt_get_pay_meta') ? aksrt_get_pay_meta($post) : [];
|
||||
$price = (float) ($meta['points_price'] ?? $meta['price'] ?? 0);
|
||||
return $price > 0 ? (int) ceil($price) : 0;
|
||||
return max(0, (int) ($meta['points_price'] ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,17 @@ if (!function_exists('aksrt_render_rewards_modal_for_user')) {
|
||||
$done[$key] = true;
|
||||
$settings = \AKSRT\Settings\Settings::get_post_page_settings();
|
||||
$amounts = (array) ($settings['rewards_amounts'] ?? [5, 10, 20, 50, 100]);
|
||||
$render_amounts = [];
|
||||
foreach ($amounts as $amount) {
|
||||
try {
|
||||
$parsed = \AKSRT\Ledger\AssetAmount::balance(is_string($amount) ? $amount : '');
|
||||
if ($parsed->minor() > 0) {
|
||||
$render_amounts[] = $parsed->decimal();
|
||||
}
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$recipient = get_userdata($user_id);
|
||||
?>
|
||||
<div class="reward-modal" id="reward-modal-<?php echo (int) $user_id; ?>" data-user-id="<?php echo (int) $user_id; ?>" data-post-id="<?php echo (int) $post_id; ?>" aria-hidden="true">
|
||||
@@ -54,8 +65,8 @@ if (!function_exists('aksrt_render_rewards_modal_for_user')) {
|
||||
<h3 id="reward-title-<?php echo (int) $user_id; ?>"><?php echo esc_html((string) ($settings['rewards_text'] ?? __('赞赏', 'aksrt'))); ?></h3>
|
||||
<p class="muted-2-color em09"><?php printf(esc_html__('赞赏将直接进入 %s 的站内余额', 'aksrt'), esc_html($recipient ? $recipient->display_name : __('作者', 'aksrt'))); ?></p>
|
||||
<div class="reward-amounts" data-aksrt-reward-amounts>
|
||||
<?php foreach ($amounts as $index => $amount) : ?>
|
||||
<button type="button" class="reward-amount<?php echo $index === 0 ? ' is-active' : ''; ?>" data-reward-amount="<?php echo esc_attr(number_format((float) $amount, 2, '.', '')); ?>">¥<?php echo esc_html(number_format_i18n((float) $amount, 2)); ?></button>
|
||||
<?php foreach ($render_amounts as $index => $amount) : ?>
|
||||
<button type="button" class="reward-amount<?php echo $index === 0 ? ' is-active' : ''; ?>" data-reward-amount="<?php echo esc_attr($amount); ?>">¥<?php echo esc_html($amount); ?></button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php if (!empty($settings['rewards_custom'])) : ?>
|
||||
@@ -75,7 +86,8 @@ add_action('wp_ajax_aksrt_submit_tip', function (): void {
|
||||
if (!is_user_logged_in()) {
|
||||
wp_send_json_error(['message' => __('请先登录后赞赏', 'aksrt')], 401);
|
||||
}
|
||||
if (!aksrt_rewards_enabled() || !class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
|
||||
$recharge = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_recharge_settings() : [];
|
||||
if (!aksrt_rewards_enabled() || empty($recharge['enabled'])) {
|
||||
wp_send_json_error(['message' => __('赞赏功能当前不可用', 'aksrt')], 400);
|
||||
}
|
||||
|
||||
@@ -94,40 +106,63 @@ add_action('wp_ajax_aksrt_submit_tip', function (): void {
|
||||
}
|
||||
|
||||
$settings = \AKSRT\Settings\Settings::get_post_page_settings();
|
||||
$amount = isset($_POST['amount']) ? round((float) wp_unslash($_POST['amount']), 2) : 0.0;
|
||||
$minimum = (float) ($settings['rewards_min'] ?? 1);
|
||||
$maximum = (float) ($settings['rewards_max'] ?? 1000);
|
||||
if (!is_finite($amount) || $amount < $minimum || $amount > $maximum) {
|
||||
wp_send_json_error(['message' => sprintf(__('赞赏金额需在 ¥%1$s 至 ¥%2$s 之间', 'aksrt'), number_format_i18n($minimum, 2), number_format_i18n($maximum, 2))], 400);
|
||||
$raw_amount = isset($_POST['amount']) ? (string) wp_unslash($_POST['amount']) : '';
|
||||
try {
|
||||
$amount = \AKSRT\Ledger\AssetAmount::balance($raw_amount);
|
||||
$minimum = \AKSRT\Ledger\AssetAmount::balance(is_string($settings['rewards_min'] ?? null) ? $settings['rewards_min'] : '');
|
||||
$maximum = \AKSRT\Ledger\AssetAmount::balance(is_string($settings['rewards_max'] ?? null) ? $settings['rewards_max'] : '');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
wp_send_json_error(['message' => __('赞赏金额格式无效', 'aksrt')], 400);
|
||||
}
|
||||
if ($amount->minor() < $minimum->minor() || $amount->minor() > $maximum->minor()) {
|
||||
wp_send_json_error(['message' => sprintf(__('赞赏金额需在 ¥%1$s 至 ¥%2$s 之间', 'aksrt'), $minimum->formatted(), $maximum->formatted())], 400);
|
||||
}
|
||||
if (empty($settings['rewards_custom'])) {
|
||||
$allowed_amounts = array_map(static fn($value): float => round((float) $value, 2), (array) ($settings['rewards_amounts'] ?? []));
|
||||
if (!in_array($amount, $allowed_amounts, true)) {
|
||||
$allowed_amounts = [];
|
||||
foreach ((array) ($settings['rewards_amounts'] ?? []) as $allowed) {
|
||||
try {
|
||||
$allowed_amounts[] = \AKSRT\Ledger\AssetAmount::balance(is_string($allowed) ? $allowed : '')->minor();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!in_array($amount->minor(), $allowed_amounts, true)) {
|
||||
wp_send_json_error(['message' => __('请选择预设的赞赏金额', 'aksrt')], 400);
|
||||
}
|
||||
}
|
||||
$request_key = sanitize_text_field(wp_unslash((string) ($_SERVER['HTTP_IDEMPOTENCY_KEY'] ?? '')));
|
||||
if (!wp_is_uuid($request_key)) {
|
||||
wp_send_json_error(['message' => __('缺少或无效的 Idempotency-Key 请求头', 'aksrt')], 400);
|
||||
}
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('client-tip', [$sender_id, strtolower($request_key)]);
|
||||
|
||||
$rate_key = 'aksrt_tip_rate_' . $sender_id;
|
||||
if (get_transient($rate_key)) {
|
||||
$recent_operation = (string) get_transient($rate_key);
|
||||
if ($recent_operation !== '' && !hash_equals($recent_operation, $operation_key)) {
|
||||
wp_send_json_error(['message' => __('操作太频繁,请稍后再试', 'aksrt')], 429);
|
||||
}
|
||||
set_transient($rate_key, 1, 3);
|
||||
set_transient($rate_key, $operation_key, 3);
|
||||
|
||||
$result = \AKSRT\Credits\Balance::transfer(
|
||||
$result = \AKSRT\Ledger\LedgerService::get_instance()->transfer(
|
||||
$sender_id,
|
||||
$recipient_id,
|
||||
$amount,
|
||||
'tip_sent',
|
||||
'tip_received',
|
||||
\AKSRT\Ledger\AssetType::BALANCE,
|
||||
$amount->minor(),
|
||||
0,
|
||||
$operation_key,
|
||||
'tip',
|
||||
['type' => 'tip', 'id' => $post_id],
|
||||
$post_id > 0 ? sprintf(__('赞赏文章 #%d', 'aksrt'), $post_id) : __('用户赞赏', 'aksrt')
|
||||
$post_id > 0 ? sprintf(__('赞赏文章 #%d', 'aksrt'), $post_id) : __('用户赞赏', 'aksrt'),
|
||||
__('收到用户赞赏', 'aksrt')
|
||||
);
|
||||
if (is_wp_error($result)) {
|
||||
wp_send_json_error(['message' => $result->get_error_message()], 400);
|
||||
wp_send_json_error(['message' => $result->get_error_message()], (int) ($result->get_error_data()['status'] ?? 400));
|
||||
}
|
||||
|
||||
wp_send_json_success([
|
||||
'message' => __('赞赏成功,金额已进入对方余额', 'aksrt'),
|
||||
'balance' => \AKSRT\Credits\Balance::format((float) $result['sender_balance']),
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
'balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($sender_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -48,54 +48,3 @@ function aksrt_get_orders_page_view(int $user_id, int $paged = 1, int $per_page
|
||||
|
||||
return $empty;
|
||||
}
|
||||
|
||||
function aksrt_get_revenue_page_view(int $user_id): array {
|
||||
$balance = 0.0;
|
||||
$total_earned = 0.0;
|
||||
$total_withdrawn = 0.0;
|
||||
$records = [];
|
||||
|
||||
if (class_exists('\AKSRT\Credits\Balance')) {
|
||||
$balance = (float) \AKSRT\Credits\Balance::balance($user_id);
|
||||
}
|
||||
|
||||
if (class_exists('\AKSRT\Payment\Revenue')) {
|
||||
$revenues = \AKSRT\Payment\Revenue::get_instance()->get_user_revenues($user_id, ['limit' => 200]);
|
||||
foreach ($revenues as $rev) {
|
||||
$row = is_array($rev) ? $rev : (array) $rev;
|
||||
$total_earned += (float) ($row['amount'] ?? 0);
|
||||
$records[] = [
|
||||
'type' => 'earning',
|
||||
'title' => (string) ($row['description'] ?? $row['type'] ?? __('创作分成', 'aksrt')),
|
||||
'amount' => (float) ($row['amount'] ?? 0),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (class_exists('\AKSRT\Payment\Withdrawal')) {
|
||||
$withdrawals = \AKSRT\Payment\Withdrawal::get_instance()->get_user_withdrawals($user_id, ['limit' => 200]);
|
||||
foreach ($withdrawals as $w) {
|
||||
$wrow = is_array($w) ? $w : (array) $w;
|
||||
$total_withdrawn += (float) ($wrow['amount'] ?? 0);
|
||||
$records[] = [
|
||||
'type' => 'withdrawal',
|
||||
'title' => __('提现', 'aksrt'),
|
||||
'amount' => (float) ($wrow['amount'] ?? 0),
|
||||
'created_at' => (string) ($wrow['created_at'] ?? ''),
|
||||
'status' => (string) ($wrow['status'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($records, static function ($a, $b) {
|
||||
return strcmp($b['created_at'] ?? '', $a['created_at'] ?? '');
|
||||
});
|
||||
|
||||
return [
|
||||
'balance' => $balance,
|
||||
'total_earned' => $total_earned,
|
||||
'total_withdrawn' => $total_withdrawn,
|
||||
'records' => $records,
|
||||
];
|
||||
}
|
||||
@@ -70,16 +70,16 @@ function aksrt_get_user_dashboard_view(int $user_id, array $args = []): array {
|
||||
$background_url = function_exists('aksrt_get_user_cover_url') ? aksrt_get_user_cover_url($user_id) : '';
|
||||
|
||||
$credits_enabled = class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::credits_enabled();
|
||||
$credit_balance = $credits_enabled && $is_own_profile && function_exists('aksrt_user_credits')
|
||||
? (float) aksrt_user_credits($user_id)
|
||||
$credit_balance = $credits_enabled && $is_own_profile
|
||||
? (float) aksrt_user_points($user_id)
|
||||
: 0.0;
|
||||
$currency_name = $credits_enabled && class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_currency_name()
|
||||
: __('积分', 'aksrt');
|
||||
|
||||
// Cash wallet (legacy pay_balance) — separate from points.
|
||||
$balance_enabled = $is_own_profile && class_exists('\AKSRT\Credits\Balance') && \AKSRT\Credits\Balance::enabled();
|
||||
$wallet_balance = $balance_enabled ? \AKSRT\Credits\Balance::balance($user_id) : 0.0;
|
||||
$recharge = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_recharge_settings() : [];
|
||||
$balance_enabled = $is_own_profile && !empty($recharge['enabled']);
|
||||
$wallet_balance = $balance_enabled ? aksrt_user_balance_decimal($user_id) : '0.00';
|
||||
|
||||
$user_home_manager = $include_governance && class_exists('\AKSRT\User\Home')
|
||||
? \AKSRT\User\Home::get_instance()
|
||||
|
||||
@@ -307,7 +307,7 @@ if (!function_exists('aksrt_check_free_download_quota')) {
|
||||
];
|
||||
// Optional overbuy price for free-quota exhausted (legacy download_limit_over_price).
|
||||
// Callers may pass post context via filter.
|
||||
$over = apply_filters('aksrt_download_limit_over_price', 0.0, $user_id);
|
||||
$over = apply_filters('aksrt_download_limit_over_price', 0, $user_id);
|
||||
if ($over > 0) {
|
||||
$data['download_limit_over_price'] = $over;
|
||||
$data['allow_limit_over'] = true;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
$balance = (float) ($credit_balance ?? 0.0);
|
||||
$balance = max(0, (int) ($credit_balance ?? 0));
|
||||
$credits_enabled = !empty($credits_enabled);
|
||||
if (!$credits_enabled) {
|
||||
return;
|
||||
@@ -26,7 +26,8 @@ if ($points_transfer_on && class_exists('\AKSRT\Settings\Capabilities') && !\AKS
|
||||
}
|
||||
$points_transfer_min = max(1, (int) ($all_settings['points_transfer_min'] ?? 1));
|
||||
$points_exchange = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_recharge_settings()['points_exchange'] ?? [] : [];
|
||||
$points_exchange_on = !empty($points_exchange['enabled']) && class_exists('\AKSRT\Credits\Balance') && \AKSRT\Credits\Balance::enabled();
|
||||
$recharge = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_recharge_settings() : [];
|
||||
$points_exchange_on = !empty($points_exchange['enabled']) && !empty($recharge['enabled']);
|
||||
$currency = (string) ($currency_name ?? __('积分', 'aksrt'));
|
||||
?>
|
||||
<div class="uc-asset-card uc-asset-card--points">
|
||||
@@ -45,7 +46,7 @@ $currency = (string) ($currency_name ?? __('积分', 'aksrt'));
|
||||
</div>
|
||||
<div class="uc-asset-card__body flex jsb ac">
|
||||
<div class="uc-asset-card__amount c-green">
|
||||
<span class="uc-asset-card__value font-bold"><?php echo esc_html(function_exists('aksrt_format_credits') ? aksrt_format_credits((int) $balance) : (string) (int) $balance); ?></span>
|
||||
<span class="uc-asset-card__value font-bold"><?php echo esc_html(aksrt_format_points($balance)); ?></span>
|
||||
</div>
|
||||
<div class="uc-asset-card__actions flex0">
|
||||
<?php if ($points_transfer_on) : ?>
|
||||
@@ -74,7 +75,7 @@ $currency = (string) ($currency_name ?? __('积分', 'aksrt'));
|
||||
<?php endif; ?>
|
||||
<form data-aksrt-points-exchange-form>
|
||||
<label class="muted-color em09"><?php esc_html_e('兑换余额', 'aksrt'); ?></label>
|
||||
<input type="number" class="uc-card-pass__input" name="amount" min="1" step="1" inputmode="numeric" data-aksrt-points-exchange-amount data-aksrt-points-exchange-rate="<?php echo esc_attr((string) $pb_rate); ?>">
|
||||
<input type="number" class="uc-card-pass__input" name="amount" min="0.01" step="0.01" inputmode="decimal" data-aksrt-points-exchange-amount data-aksrt-points-exchange-rate="<?php echo esc_attr((string) $pb_rate); ?>">
|
||||
<p class="muted-2-color em09 mt6"><?php printf(esc_html__('1 余额 = %d 积分;单笔至少 %d 积分,每日最多 %d 积分。', 'aksrt'), $pb_rate, $pb_min_points, $pb_daily_max_points); ?></p>
|
||||
<p class="muted-2-color em09 mt6" data-aksrt-points-exchange-preview></p>
|
||||
<button type="submit" class="but jb-blue btn-block mt10"><?php esc_html_e('立即购买', 'aksrt'); ?></button>
|
||||
|
||||
@@ -18,16 +18,12 @@ defined('ABSPATH') || exit;
|
||||
</div>
|
||||
<?php
|
||||
$credits_page = max(1, isset($_GET['credits_page']) ? (int) $_GET['credits_page'] : 1);
|
||||
if (class_exists('\AKSRT\Credits\Credits')) {
|
||||
$log = \AKSRT\Credits\Credits::get_log(['user_id' => $user_id, 'page' => $credits_page, 'per_page' => 10]);
|
||||
} else {
|
||||
$log = ['items' => [], 'total' => 0];
|
||||
}
|
||||
$log = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['user_id' => $user_id, 'asset' => 'points', 'page' => $credits_page, 'per_page' => 10]);
|
||||
if (!empty($log['items'])) :
|
||||
?>
|
||||
<div class="uc-transactions__list">
|
||||
<?php foreach ($log['items'] as $row) :
|
||||
$delta = (int) $row['delta'];
|
||||
$delta = (int) $row['delta_minor'];
|
||||
$delta_class = $delta >= 0 ? 'is-positive' : 'is-negative';
|
||||
?>
|
||||
<div class="uc-transactions__item">
|
||||
@@ -36,7 +32,7 @@ defined('ABSPATH') || exit;
|
||||
</div>
|
||||
<div class="uc-transactions__info">
|
||||
<div class="uc-transactions__reason">
|
||||
<?php echo esc_html(aksrt_credits_label((string) $row['reason'])); ?>
|
||||
<?php echo esc_html(aksrt_asset_reason_label((string) $row['reason'])); ?>
|
||||
<?php if (!empty($row['remark'])) : ?>
|
||||
<span class="uc-transactions__remark">(<?php echo esc_html((string) $row['remark']); ?>)</span>
|
||||
<?php endif; ?>
|
||||
@@ -44,6 +40,7 @@ defined('ABSPATH') || exit;
|
||||
<div class="uc-transactions__time">
|
||||
<?php echo esc_html(get_date_from_gmt((string) $row['created_at'], 'Y-m-d H:i')); ?>
|
||||
</div>
|
||||
<?php if (!empty($row['counterparty_user_id'])) : ?><div class="muted-2-color em09"><?php printf(esc_html__('对方用户 #%d', 'aksrt'), (int) $row['counterparty_user_id']); ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="uc-transactions__amount">
|
||||
<span class="uc-transactions__delta <?php echo esc_attr($delta_class); ?>">
|
||||
@@ -52,9 +49,11 @@ defined('ABSPATH') || exit;
|
||||
<span class="uc-transactions__balance">
|
||||
<?php
|
||||
/* translators: %s: balance */
|
||||
printf(esc_html__('余额 %s', 'aksrt'), esc_html((string) (int) $row['balance_after']));
|
||||
printf(esc_html__('余额 %s', 'aksrt'), esc_html((string) (int) $row['balance_after_minor']));
|
||||
?>
|
||||
</span>
|
||||
<?php if ((int) ($row['fee_minor'] ?? 0) > 0) : ?><span class="muted-2-color em09"><?php printf(esc_html__('手续费 %d', 'aksrt'), (int) $row['fee_minor']); ?></span><?php endif; ?>
|
||||
<span class="muted-2-color em09"><?php printf(esc_html__('交易 #%d', 'aksrt'), (int) $row['id']); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
@@ -13,10 +13,15 @@ if (empty($balance_enabled)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wallet = (float) ($wallet_balance ?? 0.0);
|
||||
$wallet_fmt = class_exists('\AKSRT\Credits\Balance')
|
||||
? \AKSRT\Credits\Balance::format($wallet)
|
||||
: number_format_i18n($wallet, 2);
|
||||
$normalize_balance = static function ($value): \AKSRT\Ledger\AssetAmount {
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance(is_string($value) ? $value : '');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return \AKSRT\Ledger\AssetAmount::balance('0');
|
||||
}
|
||||
};
|
||||
$wallet = $normalize_balance($wallet_balance ?? '0');
|
||||
$wallet_fmt = $wallet->formatted();
|
||||
|
||||
$recharge = class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_payment_recharge_settings')
|
||||
? \AKSRT\Settings\Settings::get_payment_recharge_settings()
|
||||
@@ -32,9 +37,9 @@ if ($withdraw_on && class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Sett
|
||||
$withdraw_on = false;
|
||||
}
|
||||
$withdraw_settings = class_exists('\\AKSRT\\Settings\\Settings') ? \AKSRT\Settings\Settings::get_withdrawal_settings() : [];
|
||||
$withdraw_min = max(0, (float) ($withdraw_settings['min_amount'] ?? 50));
|
||||
$withdraw_usage = ['remaining_count' => 0, 'remaining_amount' => 0];
|
||||
$withdraw_pending_hold = 0.0;
|
||||
$withdraw_min = $normalize_balance($withdraw_settings['min_amount'] ?? '50')->decimal();
|
||||
$withdraw_usage = ['remaining_count' => 0, 'remaining_amount' => '0.00'];
|
||||
$withdraw_pending_hold = '0.00';
|
||||
$withdraw_history = [];
|
||||
if ($withdraw_on && class_exists('\AKSRT\Payment\Withdrawal')) {
|
||||
$wd_svc = \AKSRT\Payment\Withdrawal::get_instance();
|
||||
@@ -42,7 +47,7 @@ if ($withdraw_on && class_exists('\AKSRT\Payment\Withdrawal')) {
|
||||
$withdraw_history = $wd_svc->get_user_withdrawals((int) ($user_id ?? 0), ['limit' => 30]);
|
||||
$withdraw_usage = $wd_svc->daily_usage((int) ($user_id ?? 0), $withdraw_settings);
|
||||
}
|
||||
$withdraw_processing = $withdraw_pending_hold > 0;
|
||||
$withdraw_processing = $normalize_balance($withdraw_pending_hold)->minor() > 0;
|
||||
$card_pass_balance = ['enabled' => false, 'desc' => '', 'only_password' => true];
|
||||
if (class_exists('\AKSRT\Payment\ChargeCard')) {
|
||||
$opts = \AKSRT\Payment\ChargeCard::get_instance()->get_public_options();
|
||||
@@ -90,7 +95,7 @@ $mark = (string) ($charge_view['currency_mark'] ?? '元');
|
||||
</div>
|
||||
<?php if ($withdraw_on) : ?>
|
||||
<div class="uc-asset-card__foot muted-2-color em09 mt6">
|
||||
<span><?php printf(esc_html__('冻结中 %s', 'aksrt'), esc_html(class_exists('\AKSRT\Credits\Balance') ? \AKSRT\Credits\Balance::format((float) $withdraw_pending_hold) : number_format_i18n((float) $withdraw_pending_hold, 2))); ?></span>
|
||||
<span><?php printf(esc_html__('冻结中 %s', 'aksrt'), esc_html($normalize_balance($withdraw_pending_hold)->formatted())); ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -128,13 +133,17 @@ $mark = (string) ($charge_view['currency_mark'] ?? '元');
|
||||
<?php if ($packages) : ?>
|
||||
<div class="muted-color mb6"><?php esc_html_e('请选择充值金额', 'aksrt'); ?></div>
|
||||
<div class="uc-charge-packages">
|
||||
<?php foreach ($packages as $idx => $pkg) : ?>
|
||||
<?php foreach ($packages as $idx => $pkg) :
|
||||
$package_credit = $normalize_balance($pkg['price'] ?? '');
|
||||
$package_pay = $normalize_balance($pkg['pay_price'] ?? '');
|
||||
$package_saving_minor = max(0, $package_credit->minor() - $package_pay->minor());
|
||||
?>
|
||||
<label class="uc-charge-package<?php echo $idx === 0 ? ' is-active' : ''; ?>">
|
||||
<input type="radio" name="balance_package" value="<?php echo esc_attr((string) $pkg['key']); ?>" <?php checked($idx === 0); ?>>
|
||||
<div class="uc-charge-package__credit"><span class="px12">¥</span><?php echo esc_html((string) $pkg['price']); ?></div>
|
||||
<div class="uc-charge-package__pay c-red"><span class="px12">¥</span><?php echo esc_html((string) $pkg['pay_price']); ?>
|
||||
<?php if ((float) $pkg['pay_price'] < (float) $pkg['price']) : ?>
|
||||
<span class="ml6 c-yellow smail"><?php printf(esc_html__('省%s', 'aksrt'), esc_html((string) ((float) $pkg['price'] - (float) $pkg['pay_price']))); ?></span>
|
||||
<?php if ($package_saving_minor > 0) : ?>
|
||||
<span class="ml6 c-yellow smail"><?php printf(esc_html__('省%s', 'aksrt'), esc_html(\AKSRT\Ledger\AssetAmount::decimal_from_minor($package_saving_minor))); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if (!empty($pkg['tag'])) : ?>
|
||||
@@ -195,19 +204,19 @@ endif; ?>
|
||||
<?php if (!empty($withdraw_history)) : ?>
|
||||
<?php foreach ($withdraw_history as $wd) :
|
||||
$st = sanitize_key((string) ($wd['status'] ?? ''));
|
||||
$gross = (float) ($wd['gross_amount'] ?? $wd['amount'] ?? 0);
|
||||
$net = (float) ($wd['amount'] ?? 0);
|
||||
$gross = $normalize_balance($wd['gross_amount'] ?? $wd['amount'] ?? '0');
|
||||
$net = $normalize_balance($wd['amount'] ?? '0');
|
||||
$st_class = $st === 'rejected' ? 'c-red' : ($st === 'completed' ? 'c-green' : 'c-blue');
|
||||
?>
|
||||
<div class="uc-withdraw-history__item border-bottom padding-h10">
|
||||
<div class="flex jsb ac">
|
||||
<span class="badg badg-sm <?php echo esc_attr($st_class); ?>"><?php echo esc_html($status_labels[$st] ?? $st); ?></span>
|
||||
<b class="em12"><?php echo esc_html(class_exists('\AKSRT\Credits\Balance') ? \AKSRT\Credits\Balance::format($gross) : number_format_i18n($gross, 2)); ?></b>
|
||||
<b class="em12"><?php echo esc_html($gross->formatted()); ?></b>
|
||||
</div>
|
||||
<div class="muted-2-color em09 mt6">
|
||||
<?php echo esc_html((string) ($wd['created_at'] ?? '')); ?>
|
||||
<?php if (abs($gross - $net) > 0.001) : ?>
|
||||
· <?php printf(esc_html__('到账 %s', 'aksrt'), esc_html(class_exists('\AKSRT\Credits\Balance') ? \AKSRT\Credits\Balance::format($net) : number_format_i18n($net, 2))); ?>
|
||||
<?php if ($gross->minor() !== $net->minor()) : ?>
|
||||
· <?php printf(esc_html__('到账 %s', 'aksrt'), esc_html($net->formatted())); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if (!empty($wd['note'])) : ?>
|
||||
@@ -233,16 +242,14 @@ endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
$wallet_page = max(1, isset($_GET['wallet_page']) ? (int) $_GET['wallet_page'] : 1);
|
||||
$wlog = class_exists('\AKSRT\Credits\Balance')
|
||||
? \AKSRT\Credits\Balance::get_log(['user_id' => (int) $user_id, 'page' => $wallet_page, 'per_page' => 10])
|
||||
: ['items' => [], 'total' => 0];
|
||||
$wlog = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['user_id' => (int) $user_id, 'asset' => 'balance', 'page' => $wallet_page, 'per_page' => 10]);
|
||||
if (!empty($wlog['items'])) :
|
||||
?>
|
||||
<div class="uc-transactions__list">
|
||||
<?php foreach ($wlog['items'] as $row) :
|
||||
$delta = (float) $row['delta'];
|
||||
$delta = (int) $row['delta_minor'];
|
||||
$delta_class = $delta >= 0 ? 'is-positive' : 'is-negative';
|
||||
$delta_txt = ($delta > 0 ? '+' : '') . (class_exists('\AKSRT\Credits\Balance') ? \AKSRT\Credits\Balance::format($delta) : number_format_i18n($delta, 2));
|
||||
$delta_txt = ($delta > 0 ? '+' : '-') . \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, abs($delta))->formatted();
|
||||
?>
|
||||
<div class="uc-transactions__item">
|
||||
<div class="uc-transactions__icon <?php echo esc_attr($delta_class); ?>">
|
||||
@@ -250,7 +257,7 @@ endif; ?>
|
||||
</div>
|
||||
<div class="uc-transactions__info">
|
||||
<div class="uc-transactions__reason">
|
||||
<?php echo esc_html(class_exists('\AKSRT\Credits\Balance') ? \AKSRT\Credits\Balance::reason_label((string) $row['reason']) : (string) $row['reason']); ?>
|
||||
<?php echo esc_html(aksrt_asset_reason_label((string) $row['reason'])); ?>
|
||||
<?php if (!empty($row['remark'])) : ?>
|
||||
<span class="uc-transactions__remark">(<?php echo esc_html((string) $row['remark']); ?>)</span>
|
||||
<?php endif; ?>
|
||||
@@ -263,7 +270,7 @@ endif; ?>
|
||||
<?php
|
||||
printf(
|
||||
esc_html__('余额 %s', 'aksrt'),
|
||||
esc_html(class_exists('\AKSRT\Credits\Balance') ? \AKSRT\Credits\Balance::format((float) $row['balance_after']) : number_format_i18n((float) $row['balance_after'], 2))
|
||||
esc_html(\AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, (int) $row['balance_after_minor'])->formatted())
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
|
||||
@@ -39,7 +39,14 @@ $page_url = static function (int $target_page) use ($base_url): string {
|
||||
);
|
||||
};
|
||||
$format = static function ($amount, string $unit): string {
|
||||
return number_format_i18n((float) $amount, $unit === 'points' ? 0 : 2);
|
||||
if ($unit === 'points') {
|
||||
return aksrt_format_points(max(0, (int) $amount));
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance(is_string($amount) ? $amount : '')->formatted();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return '0.00';
|
||||
}
|
||||
};
|
||||
$note = trim((string) ($settings['note'] ?? ''));
|
||||
if ($note === '') {
|
||||
|
||||
@@ -77,17 +77,15 @@ if ($show_wallet || $show_credits) :
|
||||
// Render only the ledger portion by reusing balance log markup.
|
||||
$user_id = (int) ($user_id ?? 0);
|
||||
$wallet_page = max(1, isset($_GET['wallet_page']) ? (int) $_GET['wallet_page'] : 1);
|
||||
$wlog = class_exists('\AKSRT\Credits\Balance')
|
||||
? \AKSRT\Credits\Balance::get_log(['user_id' => $user_id, 'page' => $wallet_page, 'per_page' => 10])
|
||||
: ['items' => [], 'total' => 0];
|
||||
$wlog = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['user_id' => $user_id, 'asset' => 'balance', 'page' => $wallet_page, 'per_page' => 10]);
|
||||
?>
|
||||
<div class="uc-transactions uc-transactions--wallet">
|
||||
<?php if (!empty($wlog['items'])) : ?>
|
||||
<div class="uc-transactions__list">
|
||||
<?php foreach ($wlog['items'] as $row) :
|
||||
$delta = (float) $row['delta'];
|
||||
$delta = (int) $row['delta_minor'];
|
||||
$delta_class = $delta >= 0 ? 'is-positive' : 'is-negative';
|
||||
$delta_txt = ($delta > 0 ? '+' : '') . (class_exists('\AKSRT\Credits\Balance') ? \AKSRT\Credits\Balance::format($delta) : number_format_i18n($delta, 2));
|
||||
$delta_txt = ($delta > 0 ? '+' : '-') . \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, abs($delta))->formatted();
|
||||
?>
|
||||
<div class="uc-transactions__item">
|
||||
<div class="uc-transactions__icon <?php echo esc_attr($delta_class); ?>">
|
||||
@@ -95,7 +93,7 @@ if ($show_wallet || $show_credits) :
|
||||
</div>
|
||||
<div class="uc-transactions__info">
|
||||
<div class="uc-transactions__reason">
|
||||
<?php echo esc_html(class_exists('\AKSRT\Credits\Balance') ? \AKSRT\Credits\Balance::reason_label((string) $row['reason']) : (string) $row['reason']); ?>
|
||||
<?php echo esc_html(aksrt_asset_reason_label((string) $row['reason'])); ?>
|
||||
<?php if (!empty($row['remark'])) : ?>
|
||||
<span class="uc-transactions__remark">(<?php echo esc_html((string) $row['remark']); ?>)</span>
|
||||
<?php endif; ?>
|
||||
@@ -104,7 +102,9 @@ if ($show_wallet || $show_credits) :
|
||||
</div>
|
||||
<div class="uc-transactions__amount">
|
||||
<span class="uc-transactions__delta <?php echo esc_attr($delta_class); ?>"><?php echo esc_html($delta_txt); ?></span>
|
||||
<span class="uc-transactions__balance"><?php printf(esc_html__('余额 %s', 'aksrt'), esc_html(class_exists('\AKSRT\Credits\Balance') ? \AKSRT\Credits\Balance::format((float) ($row['balance_after'] ?? 0)) : number_format_i18n((float) ($row['balance_after'] ?? 0), 2))); ?></span>
|
||||
<span class="uc-transactions__balance"><?php printf(esc_html__('余额 %s', 'aksrt'), esc_html(\AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, (int) ($row['balance_after_minor'] ?? 0))->formatted())); ?></span>
|
||||
<?php if ((int) ($row['fee_minor'] ?? 0) > 0) : ?><span class="muted-2-color em09"><?php printf(esc_html__('手续费 %s', 'aksrt'), esc_html(\AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, (int) $row['fee_minor'])->formatted())); ?></span><?php endif; ?>
|
||||
<span class="muted-2-color em09"><?php printf(esc_html__('交易 #%d', 'aksrt'), (int) $row['id']); ?></span>
|
||||
<?php if (!empty($row['ref_id'])) : ?>
|
||||
<span class="uc-transactions__ref muted-2-color em09"><?php printf(esc_html__('单号 %s', 'aksrt'), esc_html((string) $row['ref_id'])); ?></span>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -111,9 +111,16 @@ $tabs = [
|
||||
<?php foreach ($orders as $order) :
|
||||
$number = (string) ($order['order_number'] ?? '');
|
||||
$actions = (array) ($order['allowed_actions'] ?? []);
|
||||
$amount = !empty($order['is_points'])
|
||||
? esc_html((string) ($order['currency_symbol'] ?? __('积分', 'aksrt')) . ' ' . (string) (int) round((float) ($order['amount'] ?? 0)))
|
||||
: '¥' . esc_html(number_format((float) ($order['amount'] ?? 0), 2));
|
||||
if (!empty($order['is_points'])) {
|
||||
$amount = esc_html((string) ($order['currency_symbol'] ?? __('积分', 'aksrt')) . ' ' . (string) max(0, (int) ($order['amount'] ?? 0)));
|
||||
} else {
|
||||
try {
|
||||
$order_amount = \AKSRT\Ledger\AssetAmount::balance(is_string($order['amount'] ?? null) ? $order['amount'] : '')->formatted();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$order_amount = '0.00';
|
||||
}
|
||||
$amount = '¥' . esc_html($order_amount);
|
||||
}
|
||||
?>
|
||||
<article class="aksrt-orders-page__card" data-aksrt-order-card="<?php echo esc_attr($number); ?>" data-order-status="<?php echo esc_attr((string) ($order['status_group'] ?? '')); ?>">
|
||||
<div class="aksrt-orders-page__card-rail"><span><?php echo esc_html((string) ($order['product_type_label'] ?? '')); ?></span><time><?php echo esc_html((string) ($order['created_at'] ?? '')); ?></time><span class="aksrt-orders-page__card-status is-<?php echo esc_attr((string) ($order['status_group'] ?? '')); ?>"><?php echo esc_html((string) ($order['status_label'] ?? '')); ?></span></div>
|
||||
|
||||
@@ -21,22 +21,34 @@ $affiliate_on = !empty($affiliate['enabled']);
|
||||
$withdrawal = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_withdrawal_settings()
|
||||
: [];
|
||||
$withdraw_on = $affiliate_on
|
||||
&& class_exists('\AKSRT\Credits\Balance')
|
||||
&& \AKSRT\Credits\Balance::enabled();
|
||||
$recharge = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_recharge_settings() : [];
|
||||
$withdraw_on = $affiliate_on && !empty($recharge['enabled']);
|
||||
if ($withdraw_on && class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('balance_withdraw', $user_id)) {
|
||||
$withdraw_on = false;
|
||||
}
|
||||
$withdraw_min = max(1, (float) ($withdrawal['min_amount'] ?? 50));
|
||||
$normalize_balance = static function ($value): \AKSRT\Ledger\AssetAmount {
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance(is_string($value) ? $value : '');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return \AKSRT\Ledger\AssetAmount::balance('0');
|
||||
}
|
||||
};
|
||||
$signed_balance_minor = static function ($value) use ($normalize_balance): int {
|
||||
$value = is_string($value) ? trim($value) : '';
|
||||
$negative = str_starts_with($value, '-');
|
||||
$amount = $normalize_balance($negative ? substr($value, 1) : $value)->minor();
|
||||
return $negative ? -$amount : $amount;
|
||||
};
|
||||
$withdraw_min = $normalize_balance($withdrawal['min_amount'] ?? '50')->decimal();
|
||||
|
||||
$commission_stats = class_exists('\AKSRT\Payment\CommissionLedger')
|
||||
? \AKSRT\Payment\CommissionLedger::get_instance()->overview($user_id)
|
||||
: ['total' => 0, 'pending' => 0, 'settled' => 0, 'wallet' => 0];
|
||||
$pending_commission = (float) ($commission_stats['pending'] ?? 0);
|
||||
$pending_commission = $normalize_balance($commission_stats['pending'] ?? '0')->decimal();
|
||||
$vip_ratios = [];
|
||||
$commission_ratio = class_exists('\AKSRT\Settings\Settings')
|
||||
? (float) \AKSRT\Settings\Settings::affiliate_ratio_for_user($user_id)
|
||||
: 0.0;
|
||||
? (int) \AKSRT\Settings\Settings::affiliate_ratio_for_user($user_id)
|
||||
: 0;
|
||||
$details_text = trim((string) ($affiliate['promotion_text'] ?? ''));
|
||||
if ($details_text === '') {
|
||||
$details_text = __('好友通过推广链接完成符合条件的订单后,系统会按当前佣金比例记录返佣,结算后进入可提现余额。', 'aksrt');
|
||||
@@ -56,8 +68,8 @@ $base_url = function_exists('aksrt_get_user_center_tab_url')
|
||||
$reward_url = static function (string $tab, array $extra = []) use ($base_url): string {
|
||||
return add_query_arg(array_merge(['reward_tab' => $tab], $extra), $base_url);
|
||||
};
|
||||
$format_amount = static function ($amount): string {
|
||||
return number_format_i18n((float) $amount, 2);
|
||||
$format_amount = static function ($amount) use ($normalize_balance): string {
|
||||
return $normalize_balance($amount)->formatted();
|
||||
};
|
||||
|
||||
$commission_page = max(1, (int) ($revenue_request['commission_page'] ?? ($_GET['commission_page'] ?? 1)));
|
||||
@@ -151,7 +163,11 @@ $status_labels = [
|
||||
$source_user = !empty($item['source_user_id']) ? get_userdata((int) $item['source_user_id']) : null;
|
||||
$source_name = $source_user instanceof WP_User ? $source_user->display_name : __('推广用户', 'aksrt');
|
||||
?>
|
||||
<article class="uc-commission-row"><span class="uc-commission-row__icon"><i class="fa fas fa-file-alt" aria-hidden="true"></i></span><div class="uc-commission-row__main"><strong><?php echo esc_html($source_name); ?></strong><span><?php printf(esc_html__('订单 %s', 'aksrt'), esc_html((string) ($item['order_number'] ?? '-'))); ?></span><time><?php echo esc_html((string) ($item['created_at'] ?? '')); ?></time></div><div class="uc-commission-row__value"><strong><?php echo esc_html($format_amount((float) ($item['original_amount'] ?? 0) + (float) ($item['adjustment_total'] ?? 0))); ?></strong><span class="is-<?php echo esc_attr($status); ?>"><?php echo esc_html($status_labels[$status] ?? $status); ?></span></div></article>
|
||||
<?php
|
||||
$commission_minor = $signed_balance_minor($item['original_amount'] ?? '0') + $signed_balance_minor($item['adjustment_total'] ?? '0');
|
||||
$commission_amount = \AKSRT\Ledger\AssetAmount::decimal_from_minor(max(0, $commission_minor));
|
||||
?>
|
||||
<article class="uc-commission-row"><span class="uc-commission-row__icon"><i class="fa fas fa-file-alt" aria-hidden="true"></i></span><div class="uc-commission-row__main"><strong><?php echo esc_html($source_name); ?></strong><span><?php printf(esc_html__('订单 %s', 'aksrt'), esc_html((string) ($item['order_number'] ?? '-'))); ?></span><time><?php echo esc_html((string) ($item['created_at'] ?? '')); ?></time></div><div class="uc-commission-row__value"><strong><?php echo esc_html($format_amount($commission_amount)); ?></strong><span class="is-<?php echo esc_attr($status); ?>"><?php echo esc_html($status_labels[$status] ?? $status); ?></span></div></article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php if ($commission_total_pages > 1) : ?><nav class="uc-reward-pagination" aria-label="<?php esc_attr_e('佣金明细分页', 'aksrt'); ?>"><?php for ($i = 1; $i <= $commission_total_pages; $i++) : ?><a class="<?php echo $i === $commission_page ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('orders', ['commission_page' => $i])); ?>"><?php echo esc_html((string) $i); ?></a><?php endfor; ?></nav><?php endif; ?>
|
||||
|
||||
@@ -14,15 +14,19 @@ if (!$show_wallet && !$show_credits) {
|
||||
}
|
||||
|
||||
$assets_url = aksrt_get_user_center_tab_url('credits');
|
||||
$wallet = (float) ($wallet_balance ?? 0);
|
||||
$points = (float) ($credit_balance ?? 0);
|
||||
try {
|
||||
$wallet = \AKSRT\Ledger\AssetAmount::balance(is_string($wallet_balance ?? null) ? $wallet_balance : '')->formatted();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$wallet = '0.00';
|
||||
}
|
||||
$points = max(0, (int) ($credit_balance ?? 0));
|
||||
?>
|
||||
<div class="uc-sidebar__pair uc-sidebar__pair--assets">
|
||||
<?php if ($show_wallet) : ?>
|
||||
<a class="uc-summary-card uc-summary-card--wallet" href="<?php echo esc_url($assets_url); ?>" data-tab="credits">
|
||||
<span class="uc-summary-card__head"><?php esc_html_e('账户余额', 'aksrt'); ?><i class="fa fas fa-angle-right" aria-hidden="true"></i></span>
|
||||
<span class="uc-summary-card__body">
|
||||
<strong><?php echo esc_html(class_exists('\AKSRT\Credits\Balance') ? \AKSRT\Credits\Balance::format($wallet) : number_format_i18n($wallet, 2)); ?></strong>
|
||||
<strong><?php echo esc_html($wallet); ?></strong>
|
||||
<i class="fa fas fa-money-bill" aria-hidden="true"></i>
|
||||
</span>
|
||||
</a>
|
||||
@@ -31,7 +35,7 @@ $points = (float) ($credit_balance ?? 0);
|
||||
<a class="uc-summary-card uc-summary-card--points" href="<?php echo esc_url($assets_url); ?>" data-tab="credits">
|
||||
<span class="uc-summary-card__head"><?php echo esc_html((string) ($currency_name ?? __('积分', 'aksrt'))); ?><i class="fa fas fa-angle-right" aria-hidden="true"></i></span>
|
||||
<span class="uc-summary-card__body">
|
||||
<strong><?php echo esc_html(function_exists('aksrt_format_credits') ? aksrt_format_credits((int) $points) : number_format_i18n((int) $points)); ?></strong>
|
||||
<strong><?php echo esc_html(aksrt_format_points($points)); ?></strong>
|
||||
<i class="fa fas fa-money-bill" aria-hidden="true"></i>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -6,8 +6,12 @@ if (!empty($GLOBALS['aksrt_withdraw_modal_rendered'])) {
|
||||
return;
|
||||
}
|
||||
$GLOBALS['aksrt_withdraw_modal_rendered'] = true;
|
||||
$withdraw_min = max(0, (float) ($withdraw_min ?? 50));
|
||||
$withdraw_usage = is_array($withdraw_usage ?? null) ? $withdraw_usage : ['remaining_count' => 0, 'remaining_amount' => 0];
|
||||
try {
|
||||
$withdraw_min = \AKSRT\Ledger\AssetAmount::balance(is_string($withdraw_min ?? null) ? $withdraw_min : '')->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$withdraw_min = '0.00';
|
||||
}
|
||||
$withdraw_usage = is_array($withdraw_usage ?? null) ? $withdraw_usage : ['remaining_count' => 0, 'remaining_amount' => '0.00'];
|
||||
$withdraw_fee_rate = (int) ($withdraw_settings['service_charge_rate'] ?? 5);
|
||||
$withdraw_channels = array_values(array_intersect(['alipay', 'wechat'], (array) ($withdraw_settings['payout_channels'] ?? ['alipay', 'wechat'])));
|
||||
if (!$withdraw_channels) {
|
||||
@@ -21,8 +25,8 @@ if (!$withdraw_channels) {
|
||||
<h3 class="uc-modal__title"><?php esc_html_e('余额提现', 'aksrt'); ?></h3>
|
||||
<form data-aksrt-withdraw-form>
|
||||
<label class="muted-color em09"><?php esc_html_e('提现金额', 'aksrt'); ?></label>
|
||||
<input type="number" class="uc-card-pass__input" name="amount" min="<?php echo esc_attr((string) $withdraw_min); ?>" max="<?php echo esc_attr((string) ($withdraw_usage['remaining_amount'] ?? 0)); ?>" step="1" inputmode="numeric" data-aksrt-withdraw-amount data-withdraw-min="<?php echo esc_attr((string) $withdraw_min); ?>" data-withdraw-remaining-amount="<?php echo esc_attr((string) ($withdraw_usage['remaining_amount'] ?? 0)); ?>" data-withdraw-remaining-count="<?php echo esc_attr((string) ($withdraw_usage['remaining_count'] ?? 0)); ?>">
|
||||
<p class="muted-2-color em09 mt6"><?php printf(esc_html__('最低提现%s 元,今日可提现%d次,提现手续费率%d%%', 'aksrt'), esc_html((string) $withdraw_min), (int) ($withdraw_usage['remaining_count'] ?? 0), $withdraw_fee_rate); ?></p>
|
||||
<input type="number" class="uc-card-pass__input" name="amount" min="<?php echo esc_attr($withdraw_min); ?>" max="<?php echo esc_attr((string) ($withdraw_usage['remaining_amount'] ?? '0.00')); ?>" step="0.01" inputmode="decimal" data-aksrt-withdraw-amount data-withdraw-min="<?php echo esc_attr($withdraw_min); ?>" data-withdraw-remaining-amount="<?php echo esc_attr((string) ($withdraw_usage['remaining_amount'] ?? '0.00')); ?>" data-withdraw-remaining-count="<?php echo esc_attr((string) ($withdraw_usage['remaining_count'] ?? 0)); ?>">
|
||||
<p class="muted-2-color em09 mt6"><?php printf(esc_html__('最低提现%s 元,今日可提现%d次,提现手续费率%d%%', 'aksrt'), esc_html($withdraw_min), (int) ($withdraw_usage['remaining_count'] ?? 0), $withdraw_fee_rate); ?></p>
|
||||
<label class="muted-color em09 mt10"><?php esc_html_e('收款方式', 'aksrt'); ?></label>
|
||||
<select class="uc-card-pass__input" name="method" data-aksrt-withdraw-method>
|
||||
<?php foreach ($withdraw_channels as $channel) : ?>
|
||||
|
||||
@@ -53,8 +53,10 @@ function aksrt_notification_type_label(string $type): string {
|
||||
'comment_reply' => __('评论回复', 'aksrt'),
|
||||
'comment_approved' => __('评论审核', 'aksrt'),
|
||||
'download_ready' => __('下载就绪', 'aksrt'),
|
||||
'credits_added' => __('积分增加', 'aksrt'),
|
||||
'credits_deducted' => __('积分扣减', 'aksrt'),
|
||||
'asset_points_increase' => __('积分增加', 'aksrt'),
|
||||
'asset_points_decrease' => __('积分扣减', 'aksrt'),
|
||||
'asset_balance_increase' => __('余额增加', 'aksrt'),
|
||||
'asset_balance_decrease' => __('余额扣减', 'aksrt'),
|
||||
'pending_comments' => __('待审核评论', 'aksrt'),
|
||||
'notice' => __('通知', 'aksrt'),
|
||||
];
|
||||
@@ -549,25 +551,38 @@ add_action('transition_comment_status', function ($new_status, $old_status, $com
|
||||
update_comment_meta($comment->comment_ID, 'aksrt_reply_notified', 1);
|
||||
}, 20, 3);
|
||||
|
||||
add_action('aksrt_credits_added', function ($user_id, $delta, $reason, $new_balance) {
|
||||
if ($user_id <= 0 || $delta <= 0) return;
|
||||
aksrt_add_user_notification((int) $user_id, [
|
||||
'type' => 'credits_added',
|
||||
'title' => __('积分增加', 'aksrt'),
|
||||
'desc' => sprintf(__('积分变动:+%1$d,当前余额 %2$d。', 'aksrt'), (int) $delta, (int) $new_balance),
|
||||
'url' => aksrt_get_user_center_url('credits'),
|
||||
]);
|
||||
}, 10, 4);
|
||||
|
||||
add_action('aksrt_credits_deducted', function ($user_id, $cost, $reason, $new_balance) {
|
||||
if ($user_id <= 0 || $cost <= 0) return;
|
||||
aksrt_add_user_notification((int) $user_id, [
|
||||
'type' => 'credits_deducted',
|
||||
'title' => __('积分扣减', 'aksrt'),
|
||||
'desc' => sprintf(__('积分变动:-%1$d,当前余额 %2$d。', 'aksrt'), (int) $cost, (int) $new_balance),
|
||||
'url' => aksrt_get_user_center_url('credits'),
|
||||
]);
|
||||
}, 10, 4);
|
||||
add_action('aksrt_asset_transaction_committed', function ($result, $command): void {
|
||||
if (!$result instanceof \AKSRT\Ledger\LedgerResult || !$command instanceof \AKSRT\Ledger\LedgerCommand) {
|
||||
return;
|
||||
}
|
||||
$changes = [];
|
||||
foreach ($result->entries as $entry) {
|
||||
$user_id = (int) ($entry['user_id'] ?? 0);
|
||||
$asset = (string) ($entry['asset'] ?? '');
|
||||
if ($user_id <= 0 || !in_array($asset, ['points', 'balance'], true)) continue;
|
||||
$key = $user_id . ':' . $asset;
|
||||
$changes[$key] ??= ['user_id' => $user_id, 'asset' => $asset, 'delta' => 0, 'balance' => 0];
|
||||
$changes[$key]['delta'] += (int) ($entry['delta_minor'] ?? 0);
|
||||
$changes[$key]['balance'] = (int) ($entry['balance_after_minor'] ?? 0);
|
||||
}
|
||||
foreach ($changes as $change) {
|
||||
if ((int) $change['delta'] === 0) continue;
|
||||
$is_points = $change['asset'] === 'points';
|
||||
$positive = $change['delta'] > 0;
|
||||
$amount = $is_points
|
||||
? (string) abs((int) $change['delta'])
|
||||
: \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, abs((int) $change['delta']))->decimal();
|
||||
$balance = $is_points
|
||||
? (string) $change['balance']
|
||||
: \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, (int) $change['balance'])->decimal();
|
||||
aksrt_add_user_notification((int) $change['user_id'], [
|
||||
'type' => 'asset_' . $change['asset'] . '_' . ($positive ? 'increase' : 'decrease'),
|
||||
'title' => $is_points ? __('积分变动', 'aksrt') : __('余额变动', 'aksrt'),
|
||||
'desc' => sprintf(__('%1$s:%2$s%3$s,当前余额 %4$s。', 'aksrt'), \AKSRT\Ledger\LedgerPresentation::reason_label($command->reason), $positive ? '+' : '-', $amount, $balance),
|
||||
'url' => aksrt_get_user_center_url('credits'),
|
||||
]);
|
||||
}
|
||||
}, 10, 2);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit bootstrap="tests/asset-ledger/bootstrap.php" colors="true" cacheDirectory=".phpunit.cache">
|
||||
<testsuites>
|
||||
<testsuite name="Asset ledger unit tests">
|
||||
<directory>tests/asset-ledger</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
@@ -23,8 +23,20 @@ abstract class ControllerBase {
|
||||
/**
|
||||
* Permission callback — requires manage_options capability.
|
||||
*/
|
||||
public function check_permission(): bool {
|
||||
return current_user_can('manage_options');
|
||||
public function check_permission(?\WP_REST_Request $request = null) {
|
||||
if (!current_user_can('manage_options')) {
|
||||
if ($request === null) {
|
||||
return false;
|
||||
}
|
||||
return new \WP_Error('aksrt_admin_forbidden', __('无权执行此操作', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if ($request !== null) {
|
||||
$nonce = (string) $request->get_header('X-WP-Nonce');
|
||||
if ($nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) {
|
||||
return new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -307,7 +307,9 @@ class ImportExportController extends ControllerBase {
|
||||
|
||||
// Database tables
|
||||
$expected_tables = [
|
||||
$wpdb->prefix . 'aksrt_credit_log',
|
||||
$wpdb->prefix . 'aksrt_asset_account',
|
||||
$wpdb->prefix . 'aksrt_asset_transaction',
|
||||
$wpdb->prefix . 'aksrt_asset_entry',
|
||||
$wpdb->prefix . 'aksrt_signin_log',
|
||||
$wpdb->prefix . 'aksrt_dl_token',
|
||||
$wpdb->prefix . 'aksrt_notification',
|
||||
|
||||
@@ -50,7 +50,6 @@ class PayChargeCards {
|
||||
'balance' => __('余额', 'aksrt'),
|
||||
'vip' => __('VIP', 'aksrt'),
|
||||
'credits' => __('积分', 'aksrt'),
|
||||
'points' => __('积分', 'aksrt'),
|
||||
];
|
||||
$status_labels = [
|
||||
'active' => __('可用', 'aksrt'),
|
||||
@@ -81,7 +80,7 @@ class PayChargeCards {
|
||||
echo '<p><label>' . esc_html__('前缀', 'aksrt') . '<br><input type="text" name="prefix" value="" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('面额/积分', 'aksrt') . '<br><input type="number" step="0.01" name="amount" value="10" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('VIP等级', 'aksrt') . '<br><input type="number" name="vip_level" min="1" max="2" value="1" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('VIP天数', 'aksrt') . '<br><input type="number" name="vip_days" min="1" value="30" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('VIP天数', 'aksrt') . '<br><input type="number" name="days" min="1" value="30" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('过期时间', 'aksrt') . '<br><input type="datetime-local" name="expires_at" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('备注', 'aksrt') . '<br><input type="text" name="note" value="" /></label></p>';
|
||||
submit_button(__('生成', 'aksrt'), 'primary', '', false);
|
||||
@@ -179,7 +178,7 @@ class PayChargeCards {
|
||||
echo '<tr>';
|
||||
echo '<td>' . esc_html((string) $id) . '</td>';
|
||||
echo '<td>' . esc_html($type_labels[$row_type] ?? $row_type) . '</td>';
|
||||
echo '<td><code>' . esc_html((string) ($row['password'] ?? $row['code'] ?? '')) . '</code></td>';
|
||||
echo '<td><code>' . esc_html((string) ($row['password'] ?? '')) . '</code></td>';
|
||||
echo '<td>' . esc_html((string) ($row['amount'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html($status_labels[$st] ?? $st) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['expires_at'] ?? '—')) . '</td>';
|
||||
@@ -252,10 +251,10 @@ class PayChargeCards {
|
||||
$count = isset($_POST['count']) ? (int) $_POST['count'] : 10;
|
||||
$length = isset($_POST['length']) ? (int) $_POST['length'] : 16;
|
||||
$prefix = isset($_POST['prefix']) ? sanitize_text_field((string) wp_unslash($_POST['prefix'])) : '';
|
||||
$amount = isset($_POST['amount']) ? (float) wp_unslash($_POST['amount']) : 0;
|
||||
$amount = isset($_POST['amount']) ? trim(sanitize_text_field((string) wp_unslash($_POST['amount']))) : '';
|
||||
$note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : '';
|
||||
$level = isset($_POST['vip_level']) ? max(1, min(2, (int) $_POST['vip_level'])) : 1;
|
||||
$days = isset($_POST['vip_days']) ? max(1, (int) $_POST['vip_days']) : 30;
|
||||
$days = isset($_POST['days']) ? max(1, (int) $_POST['days']) : 30;
|
||||
$expires = isset($_POST['expires_at']) ? sanitize_text_field((string) wp_unslash($_POST['expires_at'])) : '';
|
||||
if ($expires !== '') {
|
||||
$expires = str_replace('T', ' ', $expires);
|
||||
@@ -264,15 +263,30 @@ class PayChargeCards {
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'points') {
|
||||
$type = 'credits';
|
||||
if (!in_array($type, ['balance', 'vip', 'credits'], true)) {
|
||||
wp_safe_redirect($this->page_url('aksrt_charge_cards', ['aksrt_pay_msg' => 'card_batch_failed']));
|
||||
exit;
|
||||
}
|
||||
|
||||
$meta = [];
|
||||
if ($type === 'balance') {
|
||||
$meta['amount'] = max(0, $amount);
|
||||
try {
|
||||
$balance_amount = \AKSRT\Ledger\AssetAmount::balance($amount);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
wp_safe_redirect($this->page_url('aksrt_charge_cards', ['aksrt_pay_msg' => 'card_batch_failed']));
|
||||
exit;
|
||||
}
|
||||
if ($balance_amount->minor() <= 0) {
|
||||
wp_safe_redirect($this->page_url('aksrt_charge_cards', ['aksrt_pay_msg' => 'card_batch_failed']));
|
||||
exit;
|
||||
}
|
||||
$meta['amount'] = $balance_amount->decimal();
|
||||
} elseif ($type === 'credits') {
|
||||
$meta['points'] = max(0, (int) $amount);
|
||||
if (!preg_match('/^[1-9]\d*$/', $amount)) {
|
||||
wp_safe_redirect($this->page_url('aksrt_charge_cards', ['aksrt_pay_msg' => 'card_batch_failed']));
|
||||
exit;
|
||||
}
|
||||
$meta['points'] = (int) $amount;
|
||||
} elseif ($type === 'vip') {
|
||||
$meta['level'] = $level;
|
||||
$meta['days'] = $days;
|
||||
|
||||
@@ -255,9 +255,9 @@ class PayCoupons {
|
||||
$id = \AKSRT\Payment\Coupon::get_instance()->create([
|
||||
'code' => sanitize_text_field((string) wp_unslash($_POST['code'] ?? '')),
|
||||
'type' => sanitize_key((string) wp_unslash($_POST['type'] ?? 'fixed')),
|
||||
'value' => (float) ($_POST['value'] ?? 0),
|
||||
'min_amount' => (float) ($_POST['min_amount'] ?? 0),
|
||||
'max_discount' => (float) ($_POST['max_discount'] ?? 0),
|
||||
'value' => trim((string) wp_unslash($_POST['value'] ?? '0')),
|
||||
'min_amount' => trim((string) wp_unslash($_POST['min_amount'] ?? '0')),
|
||||
'max_discount' => trim((string) wp_unslash($_POST['max_discount'] ?? '0')),
|
||||
'usage_limit' => (int) ($_POST['usage_limit'] ?? 0),
|
||||
'per_user_limit' => (int) ($_POST['per_user_limit'] ?? 0),
|
||||
'product_scope' => $this->parse_product_scope_from_request(),
|
||||
@@ -281,9 +281,9 @@ class PayCoupons {
|
||||
if (class_exists('\AKSRT\Payment\Coupon')) {
|
||||
$result = \AKSRT\Payment\Coupon::get_instance()->batch_create([
|
||||
'type' => sanitize_key((string) wp_unslash($_POST['type'] ?? 'fixed')),
|
||||
'value' => (float) ($_POST['value'] ?? 0),
|
||||
'min_amount' => (float) ($_POST['min_amount'] ?? 0),
|
||||
'max_discount' => (float) ($_POST['max_discount'] ?? 0),
|
||||
'value' => trim((string) wp_unslash($_POST['value'] ?? '0')),
|
||||
'min_amount' => trim((string) wp_unslash($_POST['min_amount'] ?? '0')),
|
||||
'max_discount' => trim((string) wp_unslash($_POST['max_discount'] ?? '0')),
|
||||
'usage_limit' => (int) ($_POST['usage_limit'] ?? 0),
|
||||
'per_user_limit' => (int) ($_POST['per_user_limit'] ?? 0),
|
||||
'product_scope' => $this->parse_product_scope_from_request(),
|
||||
@@ -315,9 +315,9 @@ class PayCoupons {
|
||||
if ($id > 0 && class_exists('\AKSRT\Payment\Coupon')) {
|
||||
$ok = \AKSRT\Payment\Coupon::get_instance()->update($id, [
|
||||
'type' => sanitize_key((string) wp_unslash($_POST['type'] ?? 'fixed')),
|
||||
'value' => (float) ($_POST['value'] ?? 0),
|
||||
'min_amount' => (float) ($_POST['min_amount'] ?? 0),
|
||||
'max_discount' => (float) ($_POST['max_discount'] ?? 0),
|
||||
'value' => trim((string) wp_unslash($_POST['value'] ?? '0')),
|
||||
'min_amount' => trim((string) wp_unslash($_POST['min_amount'] ?? '0')),
|
||||
'max_discount' => trim((string) wp_unslash($_POST['max_discount'] ?? '0')),
|
||||
'usage_limit' => (int) ($_POST['usage_limit'] ?? 0),
|
||||
'per_user_limit' => (int) ($_POST['per_user_limit'] ?? 0),
|
||||
'product_scope' => $this->parse_product_scope_from_request(),
|
||||
|
||||
@@ -60,18 +60,26 @@ class PayIncome {
|
||||
|
||||
$cash_stats = (array) ($stats['cash'] ?? []);
|
||||
$points_stats = (array) ($stats['points'] ?? []);
|
||||
$cash_amount = static function ($value): string {
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance(is_string($value) ? $value : '')->formatted();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return '0.00';
|
||||
}
|
||||
};
|
||||
echo '<p>' . esc_html(sprintf(
|
||||
__('现金:合计 ¥%1$s · 待结算 ¥%2$s · 已结算 ¥%3$s;积分:合计 %4$s · 待结算 %5$s · 已结算 %6$s', 'aksrt'),
|
||||
number_format((float) ($cash_stats['total'] ?? 0), 2),
|
||||
number_format((float) ($cash_stats['pending'] ?? 0), 2),
|
||||
number_format((float) ($cash_stats['settled'] ?? 0), 2),
|
||||
number_format((float) ($points_stats['total'] ?? 0), 0),
|
||||
number_format((float) ($points_stats['pending'] ?? 0), 0),
|
||||
number_format((float) ($points_stats['settled'] ?? 0), 0)
|
||||
$cash_amount($cash_stats['total'] ?? '0'),
|
||||
$cash_amount($cash_stats['pending'] ?? '0'),
|
||||
$cash_amount($cash_stats['settled'] ?? '0'),
|
||||
number_format_i18n(max(0, (int) ($points_stats['total'] ?? 0))),
|
||||
number_format_i18n(max(0, (int) ($points_stats['pending'] ?? 0))),
|
||||
number_format_i18n(max(0, (int) ($points_stats['settled'] ?? 0)))
|
||||
)) . '</p>';
|
||||
|
||||
echo '<form id="aksrt-pay-batch-settle-income" method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin:12px 0;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_batch_settle_income" />';
|
||||
echo '<input type="hidden" name="operation_key" value="' . esc_attr(wp_generate_uuid4()) . '" />';
|
||||
wp_nonce_field('aksrt_pay_batch_settle_income');
|
||||
submit_button(__('批量结算选中记录', 'aksrt'), 'secondary', '', false);
|
||||
echo '</form>';
|
||||
@@ -135,8 +143,10 @@ class PayIncome {
|
||||
echo '<td><code>' . esc_html((string) ($row['order_number'] ?? '')) . '</code></td>';
|
||||
$is_points = (string) ($row['currency'] ?? 'CNY') === 'points';
|
||||
$row_currency = $is_points ? __('积分', 'aksrt') : '¥';
|
||||
$row_decimals = $is_points ? 0 : 2;
|
||||
echo '<td>' . esc_html(number_format((float) ($row['amount'] ?? 0), $row_decimals) . ' ' . $row_currency) . '</td>';
|
||||
$row_amount = $is_points
|
||||
? number_format_i18n(max(0, (int) ($row['amount'] ?? 0)))
|
||||
: $cash_amount($row['amount'] ?? '0');
|
||||
echo '<td>' . esc_html($row_amount . ' ' . $row_currency) . '</td>';
|
||||
echo '<td>' . esc_html($st) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
|
||||
echo '<td>';
|
||||
@@ -144,6 +154,7 @@ class PayIncome {
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_settle_income" />';
|
||||
echo '<input type="hidden" name="revenue_id" value="' . esc_attr((string) $id) . '" />';
|
||||
echo '<input type="hidden" name="operation_key" value="' . esc_attr(wp_generate_uuid4()) . '" />';
|
||||
wp_nonce_field('aksrt_pay_settle_income_' . $id);
|
||||
submit_button(__('结算', 'aksrt'), 'small', '', false);
|
||||
echo '</form>';
|
||||
@@ -163,7 +174,11 @@ class PayIncome {
|
||||
}
|
||||
$id = isset($_POST['revenue_id']) ? (int) $_POST['revenue_id'] : 0;
|
||||
check_admin_referer('aksrt_pay_settle_income_' . $id);
|
||||
$ok = $id > 0 && class_exists('\AKSRT\Payment\Revenue') && \AKSRT\Payment\Revenue::get_instance()->settle($id, get_current_user_id());
|
||||
$request_key = sanitize_text_field((string) wp_unslash($_POST['operation_key'] ?? ''));
|
||||
$operation_key = wp_is_uuid($request_key)
|
||||
? \AKSRT\Ledger\OperationKey::deterministic('admin-revenue-settle', [get_current_user_id(), strtolower($request_key)])
|
||||
: '';
|
||||
$ok = $id > 0 && $operation_key !== '' && class_exists('\AKSRT\Payment\Revenue') && \AKSRT\Payment\Revenue::get_instance()->settle($id, get_current_user_id(), $operation_key);
|
||||
wp_safe_redirect($this->page_url('aksrt_income', ['aksrt_pay_msg' => $ok ? 'income_settled' : 'income_failed']));
|
||||
exit;
|
||||
}
|
||||
@@ -187,8 +202,12 @@ class PayIncome {
|
||||
}
|
||||
}
|
||||
$ids = array_values(array_unique($ids));
|
||||
$batch_result = !empty($ids) && class_exists('\\AKSRT\\Payment\\Revenue')
|
||||
? \AKSRT\Payment\Revenue::get_instance()->settle_batch($ids, get_current_user_id())
|
||||
$request_key = sanitize_text_field((string) wp_unslash($_POST['operation_key'] ?? ''));
|
||||
$operation_key = wp_is_uuid($request_key)
|
||||
? \AKSRT\Ledger\OperationKey::deterministic('admin-revenue-settle-batch', [get_current_user_id(), strtolower($request_key)])
|
||||
: '';
|
||||
$batch_result = !empty($ids) && $operation_key !== '' && class_exists('\\AKSRT\\Payment\\Revenue')
|
||||
? \AKSRT\Payment\Revenue::get_instance()->settle_batch($ids, get_current_user_id(), $operation_key)
|
||||
: ['settled' => [], 'failed' => $ids];
|
||||
$message = empty($batch_result['settled'])
|
||||
? 'income_failed'
|
||||
|
||||
@@ -107,17 +107,6 @@ class PayMenus {
|
||||
wp_die(esc_html__('分成模块未初始化。', 'aksrt'));
|
||||
},
|
||||
],
|
||||
'rebate' => [
|
||||
'slug' => 'aksrt_rebate',
|
||||
'title' => __('推广返佣', 'aksrt'),
|
||||
'callback' => static function (): void {
|
||||
if (class_exists('AKSRT\\Admin\\Native\\PayRebate')) {
|
||||
PayRebate::get_instance()->render_rebate_page();
|
||||
return;
|
||||
}
|
||||
wp_die(esc_html__('返佣模块未初始化。', 'aksrt'));
|
||||
},
|
||||
],
|
||||
'withdraw' => [
|
||||
'slug' => 'aksrt_withdraw',
|
||||
'title' => __('提现审核', 'aksrt'),
|
||||
@@ -224,7 +213,7 @@ class PayMenus {
|
||||
$this->stat_card(__('订单总数', 'aksrt'), (string) (int) ($order_stats['total'] ?? 0), $this->page_url('aksrt_orders'));
|
||||
$this->stat_card(__('待支付', 'aksrt'), (string) (int) ($order_stats['pending'] ?? 0), $this->page_url('aksrt_orders', ['status' => 'pending']));
|
||||
$this->stat_card(__('已支付', 'aksrt'), (string) (int) ($order_stats['paid'] ?? 0), $this->page_url('aksrt_orders', ['status' => 'paid']));
|
||||
$this->stat_card(__('营收', 'aksrt'), number_format((float) ($order_stats['revenue'] ?? 0), 2), $this->page_url('aksrt_orders'));
|
||||
$this->stat_card(__('营收', 'aksrt'), (string) ($order_stats['revenue'] ?? '0.00'), $this->page_url('aksrt_orders'));
|
||||
$this->stat_card(__('待审提现', 'aksrt'), (string) (int) ($withdraw_stats['pending'] ?? 0), $this->page_url('aksrt_withdraw', ['status' => 'pending']));
|
||||
$this->stat_card(__('可用卡密', 'aksrt'), (string) (int) ($card_stats['active'] ?? 0), $this->page_url('aksrt_charge_cards', ['status' => 'active']));
|
||||
echo '</div>';
|
||||
|
||||
@@ -213,7 +213,7 @@ class PayOrders {
|
||||
echo '<td><code>' . esc_html($order_number) . '</code></td>';
|
||||
echo '<td>' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['product_type'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html(number_format((float) ($row['amount'] ?? 0), 2)) . ' ' . esc_html((string) ($row['currency'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html($this->cash_decimal((string) ($row['amount'] ?? '0'))) . ' ' . esc_html((string) ($row['currency'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html($status_label) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
|
||||
echo '<td style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">';
|
||||
@@ -296,7 +296,7 @@ class PayOrders {
|
||||
__('用户', 'aksrt') => $user ? $user->display_name . ' (#' . $user_id . ')' : (string) $user_id,
|
||||
__('商品类型', 'aksrt') => (string) ($order['product_type'] ?? ''),
|
||||
__('商品 ID', 'aksrt') => (string) ($order['product_id'] ?? ''),
|
||||
__('金额', 'aksrt') => number_format((float) ($order['amount'] ?? 0), 2) . ' ' . (string) ($order['currency'] ?? ''),
|
||||
__('金额', 'aksrt') => $this->cash_decimal((string) ($order['amount'] ?? '0')) . ' ' . (string) ($order['currency'] ?? ''),
|
||||
__('状态', 'aksrt') => $labels[$status] ?? $status,
|
||||
__('支付方式', 'aksrt') => (string) ($order['payment_method'] ?? ''),
|
||||
__('交易号', 'aksrt') => (string) ($order['payment_transaction_id'] ?? ''),
|
||||
@@ -465,6 +465,14 @@ class PayOrders {
|
||||
}
|
||||
}
|
||||
|
||||
private function cash_decimal(string $value): string {
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return '0.00';
|
||||
}
|
||||
}
|
||||
|
||||
private function render_header(string $title): void {
|
||||
echo '<div class="wrap">';
|
||||
echo '<h1>' . esc_html($title) . '</h1>';
|
||||
|
||||
@@ -94,17 +94,16 @@ class PayProducts {
|
||||
$modo_label = $modo === 'points' ? __('积分', 'aksrt') : __('现金', 'aksrt');
|
||||
|
||||
if ($modo === 'points') {
|
||||
$list_price = (int) ceil((float) ($meta['points_price'] ?? $meta['price'] ?? 0));
|
||||
$list_price = max(0, (int) ($meta['points_price'] ?? 0));
|
||||
$price_text = sprintf(__('%d 积分', 'aksrt'), $list_price);
|
||||
} else {
|
||||
$list_price = round((float) ($meta['price'] ?? 0), 2);
|
||||
$price_text = number_format($list_price, 2);
|
||||
$price_text = $this->cash_decimal((string) ($meta['price'] ?? '0'));
|
||||
}
|
||||
|
||||
$sales = $sales_map[$post_id] ?? ['sales_count' => 0, 'avg_amount' => 0.0, 'revenue' => 0.0];
|
||||
$sales = $sales_map[$post_id] ?? ['sales_count' => 0, 'avg_amount' => '0.00', 'revenue' => '0.00'];
|
||||
$db_sales = (int) ($sales['sales_count'] ?? 0);
|
||||
$avg = (float) ($sales['avg_amount'] ?? 0);
|
||||
$revenue = (float) ($sales['revenue'] ?? 0);
|
||||
$avg = $this->cash_decimal((string) ($sales['avg_amount'] ?? '0'));
|
||||
$revenue = $this->cash_decimal((string) ($sales['revenue'] ?? '0'));
|
||||
$meta_sales = (int) get_post_meta($post_id, \AKSRT\Core\DomainModel::RESOURCE_SALES_VOLUME_KEY, true);
|
||||
|
||||
$edit_url = get_edit_post_link($post_id, 'raw');
|
||||
@@ -130,10 +129,10 @@ class PayProducts {
|
||||
echo '<td>' . esc_html((string) $pt_label) . '</td>';
|
||||
echo '<td>' . esc_html($modo_label) . '</td>';
|
||||
echo '<td>' . esc_html($price_text) . '</td>';
|
||||
echo '<td>' . esc_html(number_format($avg, 2)) . '</td>';
|
||||
echo '<td>' . esc_html($avg) . '</td>';
|
||||
echo '<td>' . esc_html((string) $db_sales) . '</td>';
|
||||
echo '<td>' . esc_html((string) max(0, $meta_sales)) . '</td>';
|
||||
echo '<td>' . esc_html(number_format($revenue, 2)) . '</td>';
|
||||
echo '<td>' . esc_html($revenue) . '</td>';
|
||||
echo '<td style="display:flex;gap:6px;flex-wrap:wrap;">';
|
||||
if ($edit_url) {
|
||||
echo '<a class="button button-small" href="' . esc_url($edit_url) . '">' . esc_html__('编辑', 'aksrt') . '</a>';
|
||||
@@ -337,6 +336,14 @@ class PayProducts {
|
||||
}
|
||||
}
|
||||
|
||||
private function cash_decimal(string $value): string {
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return '0.00';
|
||||
}
|
||||
}
|
||||
|
||||
private function render_header(string $title): void {
|
||||
echo '<div class="wrap">';
|
||||
echo '<h1>' . esc_html($title) . '</h1>';
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class PayRebate {
|
||||
|
||||
public const CAPABILITY = 'manage_options';
|
||||
public const PAGE_SLUG = 'aksrt_rebate';
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
if (!is_admin()) {
|
||||
return;
|
||||
}
|
||||
add_action('admin_post_aksrt_pay_settle_rebate', [$this, 'handle_settle_rebate']);
|
||||
add_action('admin_post_aksrt_pay_cancel_rebate', [$this, 'handle_cancel_rebate']);
|
||||
}
|
||||
|
||||
public function render_rebate_page(): void {
|
||||
$this->ensure_cap();
|
||||
$this->render_header(__('推广返佣', 'aksrt'));
|
||||
|
||||
if (!class_exists('\AKSRT\Payment\Rebate')) {
|
||||
echo '<div class="notice notice-error"><p>' . esc_html__('返佣模块未初始化。', 'aksrt') . '</p></div>';
|
||||
$this->render_footer();
|
||||
return;
|
||||
}
|
||||
|
||||
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
|
||||
$user_id = isset($_GET['user_id']) ? max(0, (int) $_GET['user_id']) : (isset($_GET['referrer_id']) ? max(0, (int) $_GET['referrer_id']) : 0);
|
||||
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
|
||||
$per_page = 20;
|
||||
$svc = \AKSRT\Payment\Rebate::get_instance();
|
||||
$stats = $svc->get_stats();
|
||||
|
||||
echo '<p>' . esc_html(sprintf(
|
||||
/* translators: 1: total 2: pending 3: settled amount */
|
||||
__('记录 %1$d · 待结算 %2$d · 已结算金额 %3$s', 'aksrt'),
|
||||
(int) ($stats['total'] ?? 0),
|
||||
(int) ($stats['pending'] ?? 0),
|
||||
number_format((float) ($stats['settled_amount'] ?? 0), 2)
|
||||
)) . '</p>';
|
||||
|
||||
echo '<form method="get" style="margin:12px 0;">';
|
||||
echo '<input type="hidden" name="page" value="aksrt_rebate" />';
|
||||
echo '<select name="status">';
|
||||
foreach (['' => __('全部状态', 'aksrt'), 'pending' => __('待结算', 'aksrt'), 'settled' => __('已结算', 'aksrt'), 'cancelled' => __('已取消', 'aksrt')] as $key => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($status, $key, false), esc_html($label));
|
||||
}
|
||||
echo '</select> ';
|
||||
echo '<input type="number" min="0" name="user_id" value="' . esc_attr($user_id > 0 ? (string) $user_id : '') . '" placeholder="' . esc_attr__('推广用户 ID', 'aksrt') . '" class="small-text" /> ';
|
||||
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
|
||||
if ($user_id > 0) {
|
||||
echo ' <a class="button button-small" href="' . esc_url($this->page_url('aksrt_rebate')) . '">' . esc_html__('清除用户过滤', 'aksrt') . '</a>';
|
||||
}
|
||||
echo '</form>';
|
||||
|
||||
$list_args = [
|
||||
'status' => $status,
|
||||
'limit' => $per_page,
|
||||
'offset' => ($page_num - 1) * $per_page,
|
||||
];
|
||||
if ($user_id > 0) {
|
||||
$list_args['user_id'] = $user_id;
|
||||
}
|
||||
$result = $svc->get_list($list_args);
|
||||
$items = $result['items'] ?? [];
|
||||
$total = (int) ($result['total'] ?? 0);
|
||||
$pages = max(1, (int) ceil(max(1, $total) / $per_page));
|
||||
|
||||
echo '<table class="wp-list-table widefat fixed striped"><thead><tr>';
|
||||
echo '<th>ID</th><th>' . esc_html__('用户', 'aksrt') . '</th><th>' . esc_html__('订单号', 'aksrt') . '</th><th>' . esc_html__('金额', 'aksrt') . '</th><th>' . esc_html__('状态', 'aksrt') . '</th><th>' . esc_html__('时间', 'aksrt') . '</th><th>' . esc_html__('操作', 'aksrt') . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
if (!$items) {
|
||||
echo '<tr><td colspan="7">' . esc_html__('暂无返佣记录', 'aksrt') . '</td></tr>';
|
||||
}
|
||||
foreach ($items as $row) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
$user_id = (int) ($row['user_id'] ?? 0);
|
||||
$user = $user_id > 0 ? get_userdata($user_id) : null;
|
||||
$st = (string) ($row['status'] ?? '');
|
||||
echo '<tr>';
|
||||
echo '<td>' . esc_html((string) $id) . '</td>';
|
||||
echo '<td>' . ($user ? esc_html($user->display_name) : esc_html((string) $user_id)) . '</td>';
|
||||
echo '<td><code>' . esc_html((string) ($row['order_number'] ?? '')) . '</code></td>';
|
||||
echo '<td>' . esc_html(number_format((float) ($row['amount'] ?? 0), 2)) . '</td>';
|
||||
echo '<td>' . esc_html($st) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
|
||||
echo '<td style="display:flex;gap:4px;">';
|
||||
if ($st === 'pending') {
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_settle_rebate" />';
|
||||
echo '<input type="hidden" name="rebate_id" value="' . esc_attr((string) $id) . '" />';
|
||||
wp_nonce_field('aksrt_pay_settle_rebate_' . $id);
|
||||
submit_button(__('结算', 'aksrt'), 'small', '', false);
|
||||
echo '</form>';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_cancel_rebate" />';
|
||||
echo '<input type="hidden" name="rebate_id" value="' . esc_attr((string) $id) . '" />';
|
||||
wp_nonce_field('aksrt_pay_cancel_rebate_' . $id);
|
||||
submit_button(__('取消', 'aksrt'), 'small', '', false);
|
||||
echo '</form>';
|
||||
} else {
|
||||
echo '—';
|
||||
}
|
||||
echo '</td></tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
$this->render_pagination($pages, $page_num);
|
||||
$this->render_footer();
|
||||
}
|
||||
|
||||
public function handle_settle_rebate(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
}
|
||||
$id = isset($_POST['rebate_id']) ? (int) $_POST['rebate_id'] : 0;
|
||||
check_admin_referer('aksrt_pay_settle_rebate_' . $id);
|
||||
$ok = $id > 0 && class_exists('\AKSRT\Payment\Rebate') && \AKSRT\Payment\Rebate::get_instance()->settle($id);
|
||||
wp_safe_redirect($this->page_url('aksrt_rebate', ['aksrt_pay_msg' => $ok ? 'rebate_settled' : 'rebate_failed']));
|
||||
exit;
|
||||
}
|
||||
|
||||
public function handle_cancel_rebate(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
}
|
||||
$id = isset($_POST['rebate_id']) ? (int) $_POST['rebate_id'] : 0;
|
||||
check_admin_referer('aksrt_pay_cancel_rebate_' . $id);
|
||||
$ok = $id > 0 && class_exists('\AKSRT\Payment\Rebate') && \AKSRT\Payment\Rebate::get_instance()->cancel($id);
|
||||
wp_safe_redirect($this->page_url('aksrt_rebate', ['aksrt_pay_msg' => $ok ? 'rebate_cancelled' : 'rebate_failed']));
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function ensure_cap(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
|
||||
}
|
||||
}
|
||||
|
||||
private function render_header(string $title): void {
|
||||
echo '<div class="wrap">';
|
||||
echo '<h1>' . esc_html($title) . '</h1>';
|
||||
$this->render_flash();
|
||||
}
|
||||
|
||||
private function render_footer(): void {
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
private function render_flash(): void {
|
||||
if (empty($_GET['aksrt_pay_msg'])) {
|
||||
return;
|
||||
}
|
||||
$code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg']));
|
||||
$map = [
|
||||
'rebate_settled' => __('返佣已结算。', 'aksrt'),
|
||||
'rebate_cancelled' => __('返佣已取消。', 'aksrt'),
|
||||
'rebate_failed' => __('返佣操作失败。', 'aksrt'),
|
||||
];
|
||||
if (!isset($map[$code])) {
|
||||
return;
|
||||
}
|
||||
$class = str_contains($code, 'failed') ? 'notice-error' : 'notice-success';
|
||||
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($map[$code]) . '</p></div>';
|
||||
}
|
||||
|
||||
private function page_url(string $slug, array $args = []): string {
|
||||
return add_query_arg(array_merge(['page' => $slug], $args), admin_url('admin.php'));
|
||||
}
|
||||
|
||||
private function render_pagination(int $pages, int $page_num): void {
|
||||
if ($pages <= 1) {
|
||||
return;
|
||||
}
|
||||
echo '<div class="tablenav"><div class="tablenav-pages">';
|
||||
echo paginate_links([
|
||||
'base' => add_query_arg('paged', '%#%'),
|
||||
'format' => '',
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
'total' => $pages,
|
||||
'current' => $page_num,
|
||||
]);
|
||||
echo '</div></div>';
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,11 @@ class PayWithdrawals {
|
||||
$per_page = 20;
|
||||
$svc = \AKSRT\Payment\Withdrawal::get_instance();
|
||||
$stats = $svc->get_stats();
|
||||
$retry_id = isset($_GET['retry_id']) ? max(0, (int) $_GET['retry_id']) : 0;
|
||||
$retry_key = isset($_GET['retry_key']) ? sanitize_text_field((string) wp_unslash($_GET['retry_key'])) : '';
|
||||
if (!wp_is_uuid($retry_key)) {
|
||||
$retry_key = '';
|
||||
}
|
||||
|
||||
echo '<p>' . esc_html(sprintf(
|
||||
/* translators: 1: total 2: pending 3: completed 4: paid */
|
||||
@@ -52,7 +57,7 @@ class PayWithdrawals {
|
||||
(int) ($stats['processing'] ?? 0),
|
||||
(int) ($stats['completed'] ?? 0),
|
||||
(int) ($stats['rejected'] ?? 0),
|
||||
number_format((float) ($stats['total_paid'] ?? 0), 2)
|
||||
(string) ($stats['total_paid'] ?? '0.00')
|
||||
)) . '</p>';
|
||||
|
||||
echo '<form method="get" style="margin:12px 0;">';
|
||||
@@ -106,17 +111,17 @@ class PayWithdrawals {
|
||||
$user_id = (int) ($row['user_id'] ?? 0);
|
||||
$user = $user_id > 0 ? get_userdata($user_id) : null;
|
||||
$st = (string) ($row['status'] ?? '');
|
||||
$gross = (float) ($row['gross_amount'] ?? $row['amount'] ?? 0);
|
||||
$net = (float) ($row['amount'] ?? 0);
|
||||
$fee = (int) ($row['fee_rate'] ?? 0);
|
||||
$acct = (string) ($row['display_account'] ?? $row['account_info'] ?? '');
|
||||
$amount_txt = number_format($net, 2);
|
||||
if ($gross > 0 && abs($gross - $net) > 0.001) {
|
||||
$amount_txt = sprintf(
|
||||
$gross = (string) ($row['gross_amount'] ?? '0.00');
|
||||
$net = (string) ($row['amount'] ?? '0.00');
|
||||
$fee = (int) ($row['fee_rate'] ?? 0);
|
||||
$acct = (string) ($row['display_account'] ?? $row['account_info'] ?? '');
|
||||
$amount_txt = $net;
|
||||
if ($gross !== $net) {
|
||||
$amount_txt = sprintf(
|
||||
/* translators: 1: gross 2: net 3: fee percent */
|
||||
__('申请 %1$s / 到账 %2$s(手续费 %3$d%%)', 'aksrt'),
|
||||
number_format($gross, 2),
|
||||
number_format($net, 2),
|
||||
$gross,
|
||||
$net,
|
||||
$fee
|
||||
);
|
||||
}
|
||||
@@ -130,15 +135,22 @@ class PayWithdrawals {
|
||||
echo '<td>' . esc_html($status_options[$st] ?? $st) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
|
||||
echo '<td>';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:flex;gap:4px;flex-wrap:wrap;align-items:center;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_update_withdrawal" />';
|
||||
echo '<input type="hidden" name="withdrawal_id" value="' . esc_attr((string) $id) . '" />';
|
||||
$next_statuses = $st === 'pending' ? ['processing', 'rejected'] : ($st === 'processing' ? ['completed', 'rejected'] : []);
|
||||
if ($next_statuses === []) {
|
||||
echo '—';
|
||||
echo '</td>';
|
||||
echo '</tr>';
|
||||
continue;
|
||||
}
|
||||
$request_key = $retry_id === $id && $retry_key !== '' ? $retry_key : wp_generate_uuid4();
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:flex;gap:4px;flex-wrap:wrap;align-items:center;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_update_withdrawal" />';
|
||||
echo '<input type="hidden" name="withdrawal_id" value="' . esc_attr((string) $id) . '" />';
|
||||
echo '<input type="hidden" name="idempotency_key" value="' . esc_attr($request_key) . '" />';
|
||||
wp_nonce_field('aksrt_pay_update_withdrawal_' . $id);
|
||||
echo '<select name="new_status">';
|
||||
foreach ($status_options as $key => $label) {
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ($next_statuses as $key) {
|
||||
$label = $status_options[$key];
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr($key),
|
||||
@@ -180,26 +192,31 @@ class PayWithdrawals {
|
||||
$id = isset($_POST['withdrawal_id']) ? (int) $_POST['withdrawal_id'] : 0;
|
||||
$new_status = isset($_POST['new_status']) ? sanitize_key((string) wp_unslash($_POST['new_status'])) : '';
|
||||
$note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : '';
|
||||
$request_key = isset($_POST['idempotency_key']) ? sanitize_text_field((string) wp_unslash($_POST['idempotency_key'])) : '';
|
||||
check_admin_referer('aksrt_pay_update_withdrawal_' . $id);
|
||||
|
||||
$msg = 'withdraw_failed';
|
||||
if ($id > 0 && $new_status !== '' && class_exists('\AKSRT\Payment\Withdrawal')) {
|
||||
$result = \AKSRT\Payment\Withdrawal::get_instance()->process_status_change($id, $new_status, $note);
|
||||
if ($id > 0 && $new_status !== '' && wp_is_uuid($request_key) && class_exists('\AKSRT\Payment\Withdrawal')) {
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('admin-withdraw-transition', [get_current_user_id(), strtolower($request_key)]);
|
||||
$result = \AKSRT\Payment\Withdrawal::get_instance()->process_status_change($id, $new_status, $note, $operation_key);
|
||||
if (!is_wp_error($result) && $result) {
|
||||
$msg = 'withdraw_updated';
|
||||
} elseif (is_wp_error($result) && $result->get_error_code() === 'aksrt_balance') {
|
||||
} elseif (is_wp_error($result) && $result->get_error_code() === 'aksrt_balance_insufficient') {
|
||||
$msg = 'withdraw_refund_failed';
|
||||
}
|
||||
}
|
||||
|
||||
wp_safe_redirect($this->page_url('aksrt_withdraw', [
|
||||
'aksrt_pay_msg' => $msg,
|
||||
]));
|
||||
$redirect_args = ['aksrt_pay_msg' => $msg];
|
||||
if ($msg !== 'withdraw_updated' && wp_is_uuid($request_key)) {
|
||||
$redirect_args['retry_id'] = $id;
|
||||
$redirect_args['retry_key'] = $request_key;
|
||||
}
|
||||
wp_safe_redirect($this->page_url('aksrt_withdraw', $redirect_args));
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Income / rebate
|
||||
* Income
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
|
||||
|
||||
+125
-260
@@ -42,17 +42,35 @@ class PaymentController extends ControllerBase {
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_creator_income'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
|
||||
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20],
|
||||
'status' => ['type' => 'string', 'enum' => ['', 'pending', 'settled']],
|
||||
'user_id' => ['type' => 'integer', 'minimum' => 0, 'default' => 0],
|
||||
'post_id' => ['type' => 'integer', 'minimum' => 0, 'default' => 0],
|
||||
'currency' => ['type' => 'string', 'enum' => ['', 'CNY', 'cny', 'points']],
|
||||
'order_status' => ['type' => 'string', 'sanitize_callback' => 'sanitize_key'],
|
||||
'order_number' => ['type' => 'string', 'maxLength' => 64, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'date_from' => ['type' => 'string', 'format' => 'date'],
|
||||
'date_to' => ['type' => 'string', 'format' => 'date'],
|
||||
],
|
||||
]);
|
||||
register_rest_route(self::API_NAMESPACE, '/payment/creator-income/(?P<id>\d+)/settle', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'settle_creator_income'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/payment/withdrawals', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_withdrawals'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
|
||||
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 50, 'default' => 20],
|
||||
'status' => ['type' => 'string', 'enum' => ['', 'pending', 'processing', 'completed', 'rejected']],
|
||||
],
|
||||
]);
|
||||
|
||||
// Withdrawal status update
|
||||
@@ -60,6 +78,11 @@ class PaymentController extends ControllerBase {
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'update_withdrawal_status'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'id' => ['type' => 'integer', 'minimum' => 1, 'required' => true],
|
||||
'status' => ['type' => 'string', 'required' => true, 'enum' => ['processing', 'completed', 'rejected']],
|
||||
'note' => ['type' => 'string', 'default' => '', 'maxLength' => 1000, 'sanitize_callback' => 'sanitize_textarea_field'],
|
||||
],
|
||||
]);
|
||||
|
||||
// ── Charge Card ──
|
||||
@@ -69,11 +92,20 @@ class PaymentController extends ControllerBase {
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_charge_cards'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
|
||||
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20],
|
||||
'status' => ['type' => 'string', 'enum' => ['', 'active', 'used', 'expired', 'disabled']],
|
||||
'keyword' => ['type' => 'string', 'maxLength' => 191, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'date_from' => ['type' => 'string', 'format' => 'date'],
|
||||
'date_to' => ['type' => 'string', 'format' => 'date'],
|
||||
],
|
||||
]);
|
||||
register_rest_route(self::API_NAMESPACE, '/payment/charge-cards/(?P<id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_charge_card_detail'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
|
||||
]);
|
||||
|
||||
// Charge card create
|
||||
@@ -81,6 +113,16 @@ class PaymentController extends ControllerBase {
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'create_charge_card'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'type' => ['type' => 'string', 'required' => true, 'enum' => ['balance', 'vip', 'credits']],
|
||||
'password' => ['type' => 'string', 'required' => true, 'minLength' => 1, 'maxLength' => 80, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'amount' => ['type' => 'string', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
'points' => ['type' => 'integer', 'minimum' => 1],
|
||||
'level' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 2],
|
||||
'days' => ['type' => 'integer', 'minimum' => 0],
|
||||
'expires_at' => ['type' => 'string', 'maxLength' => 32, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'note' => ['type' => 'string', 'maxLength' => 1000, 'sanitize_callback' => 'sanitize_textarea_field'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Charge card batch create
|
||||
@@ -88,6 +130,18 @@ class PaymentController extends ControllerBase {
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'batch_create_charge_cards'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'type' => ['type' => 'string', 'required' => true, 'enum' => ['balance', 'vip', 'credits']],
|
||||
'count' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'required' => true],
|
||||
'amount' => ['type' => 'string', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
'points' => ['type' => 'integer', 'minimum' => 1],
|
||||
'level' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 2],
|
||||
'days' => ['type' => 'integer', 'minimum' => 0],
|
||||
'prefix' => ['type' => 'string', 'maxLength' => 20, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'length' => ['type' => 'integer', 'minimum' => 8, 'maximum' => 32, 'default' => 16],
|
||||
'expires_at' => ['type' => 'string', 'maxLength' => 32, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'note' => ['type' => 'string', 'maxLength' => 1000, 'sanitize_callback' => 'sanitize_textarea_field'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Charge card disable
|
||||
@@ -95,6 +149,7 @@ class PaymentController extends ControllerBase {
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'disable_charge_card'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
|
||||
]);
|
||||
|
||||
}
|
||||
@@ -129,14 +184,14 @@ class PaymentController extends ControllerBase {
|
||||
*/
|
||||
public function get_revenue(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$revenue_stats = [
|
||||
'cash' => ['total' => 0, 'pending' => 0, 'settled' => 0, 'count' => 0],
|
||||
'cash' => ['total' => '0.00', 'pending' => '0.00', 'settled' => '0.00', 'count' => 0],
|
||||
'points' => ['total' => 0, 'pending' => 0, 'settled' => 0, 'count' => 0],
|
||||
];
|
||||
if (class_exists('\AKSRT\Payment\Revenue')) {
|
||||
$revenue_stats = \AKSRT\Payment\Revenue::get_instance()->get_stats();
|
||||
}
|
||||
|
||||
$withdrawal_stats = ['total' => 0, 'pending' => 0, 'processing' => 0, 'completed' => 0, 'rejected' => 0, 'total_paid' => 0];
|
||||
$withdrawal_stats = ['total' => 0, 'pending' => 0, 'processing' => 0, 'completed' => 0, 'rejected' => 0, 'total_paid' => '0.00'];
|
||||
if (class_exists('\AKSRT\Payment\Withdrawal')) {
|
||||
$withdrawal_stats = \AKSRT\Payment\Withdrawal::get_instance()->get_stats();
|
||||
}
|
||||
@@ -201,9 +256,19 @@ class PaymentController extends ControllerBase {
|
||||
$series = ['cash' => [], 'points' => []];
|
||||
foreach ((array) $results as $row) {
|
||||
$key = (string) ($row->currency ?? '') === 'points' ? 'points' : 'cash';
|
||||
$series[$key][] = [
|
||||
$amount = (string) ($row->total_amount ?? '0');
|
||||
if ($key === 'points') {
|
||||
$amount = preg_match('/^(0|[1-9]\d*)(?:\.0+)?$/', $amount, $matches) ? (int) $matches[1] : 0;
|
||||
} else {
|
||||
try {
|
||||
$amount = \AKSRT\Ledger\AssetAmount::balance($amount)->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$amount = '0.00';
|
||||
}
|
||||
}
|
||||
$series[$key][] = [
|
||||
'date' => (string) $row->period,
|
||||
'amount' => (float) $row->total_amount,
|
||||
'amount' => $amount,
|
||||
'count' => (int) $row->order_count,
|
||||
];
|
||||
}
|
||||
@@ -245,10 +310,29 @@ class PaymentController extends ControllerBase {
|
||||
return $this->error(__('创作分成模块未启用', 'aksrt'), 500);
|
||||
}
|
||||
$id = (int) $request->get_param('id');
|
||||
if ($id <= 0 || !\AKSRT\Payment\Revenue::get_instance()->settle($id, get_current_user_id())) {
|
||||
return $this->error(__('结算失败', 'aksrt'));
|
||||
}
|
||||
return $this->success(['message' => __('创作分成已结算', 'aksrt')]);
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-revenue-settle', get_current_user_id());
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $this->error($operation_key->get_error_message(), (int) ($operation_key->get_error_data()['status'] ?? 400));
|
||||
}
|
||||
$result = \AKSRT\Payment\Revenue::get_instance()->settle_result($id, get_current_user_id(), $operation_key);
|
||||
if (is_wp_error($result)) {
|
||||
$data = $result->get_error_data();
|
||||
return $this->error($result->get_error_message(), is_array($data) ? (int) ($data['status'] ?? 400) : 400);
|
||||
}
|
||||
$entry = (array) ($result->entries[0] ?? []);
|
||||
$asset = (string) ($entry['asset'] ?? \AKSRT\Ledger\AssetType::BALANCE->value);
|
||||
$author_id = (int) ($entry['user_id'] ?? 0);
|
||||
$balance_minor = $result->balance($author_id, $asset);
|
||||
$balance = $asset === \AKSRT\Ledger\AssetType::BALANCE->value
|
||||
? \AKSRT\Ledger\AssetAmount::decimal_from_minor($balance_minor)
|
||||
: $balance_minor;
|
||||
return $this->success([
|
||||
'message' => __('创作分成已结算', 'aksrt'),
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
'asset' => $asset,
|
||||
'balance' => $balance,
|
||||
]);
|
||||
}
|
||||
|
||||
public function get_withdrawals(\WP_REST_Request $request): \WP_REST_Response {
|
||||
@@ -293,7 +377,7 @@ class PaymentController extends ControllerBase {
|
||||
$new_status = sanitize_key((string) ($params['status'] ?? ''));
|
||||
$note = sanitize_textarea_field((string) ($params['note'] ?? ''));
|
||||
|
||||
$valid_statuses = ['pending', 'processing', 'completed', 'rejected'];
|
||||
$valid_statuses = ['processing', 'completed', 'rejected'];
|
||||
if (!in_array($new_status, $valid_statuses, true)) {
|
||||
return $this->error('无效的提现状态');
|
||||
}
|
||||
@@ -302,13 +386,18 @@ class PaymentController extends ControllerBase {
|
||||
return $this->error('无效的提现 ID');
|
||||
}
|
||||
|
||||
$result = \AKSRT\Payment\Withdrawal::get_instance()->process_status_change($id, $new_status, $note);
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-withdraw-transition', get_current_user_id());
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $this->error($operation_key->get_error_message(), (int) ($operation_key->get_error_data()['status'] ?? 400));
|
||||
}
|
||||
$result = \AKSRT\Payment\Withdrawal::get_instance()->process_status_change($id, $new_status, $note, $operation_key);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return $this->error($result->get_error_message());
|
||||
$data = $result->get_error_data();
|
||||
return $this->error($result->get_error_message(), is_array($data) ? (int) ($data['status'] ?? 400) : 400);
|
||||
}
|
||||
if ($result) {
|
||||
return $this->success(['message' => __('提现状态已更新', 'aksrt')]);
|
||||
if (is_array($result)) {
|
||||
return $this->success(array_merge(['message' => __('提现状态已更新', 'aksrt')], $result));
|
||||
}
|
||||
return $this->error(__('更新失败', 'aksrt'));
|
||||
}
|
||||
@@ -359,7 +448,6 @@ class PaymentController extends ControllerBase {
|
||||
foreach ((array) $rows as $row) {
|
||||
$item = $this->build_charge_card_admin_item($row);
|
||||
if ($keyword !== '' && mb_stripos(implode(' ', array_filter([
|
||||
(string) ($item['code'] ?? ''),
|
||||
(string) ($item['password'] ?? ''),
|
||||
(string) ($item['card'] ?? ''),
|
||||
(string) ($item['type_label'] ?? ''),
|
||||
@@ -410,7 +498,7 @@ class PaymentController extends ControllerBase {
|
||||
if (!in_array($type, ['balance', 'vip', 'credits'], true)) {
|
||||
$type = 'balance';
|
||||
}
|
||||
$password = sanitize_text_field((string) ($params['password'] ?? $params['code'] ?? ''));
|
||||
$password = sanitize_text_field((string) ($params['password'] ?? ''));
|
||||
if ($password === '') {
|
||||
return $this->error('卡密不能为空');
|
||||
}
|
||||
@@ -449,15 +537,22 @@ class PaymentController extends ControllerBase {
|
||||
$note = sanitize_textarea_field((string) ($params['note'] ?? ''));
|
||||
$expires = sanitize_text_field((string) ($params['expires_at'] ?? ''));
|
||||
|
||||
$raw_amount = $params['amount'] ?? '';
|
||||
$meta_fields = [
|
||||
'amount' => (float) ($params['amount'] ?? 0),
|
||||
'amount' => is_string($raw_amount) ? $raw_amount : '',
|
||||
'level' => (int) ($params['level'] ?? 1),
|
||||
'days' => (int) ($params['days'] ?? $params['vip_days'] ?? 30),
|
||||
'points' => (int) ($params['points'] ?? $params['credits'] ?? 0),
|
||||
'days' => (int) ($params['days'] ?? 30),
|
||||
'points' => (int) ($params['points'] ?? 0),
|
||||
];
|
||||
|
||||
if ($type === 'balance' && $meta_fields['amount'] <= 0) {
|
||||
return $this->error('余额卡金额必须大于 0');
|
||||
if ($type === 'balance') {
|
||||
try {
|
||||
$card_amount = \AKSRT\Ledger\AssetAmount::balance($meta_fields['amount']);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error('余额卡金额格式无效');
|
||||
}
|
||||
if ($card_amount->minor() <= 0) return $this->error('余额卡金额必须大于 0');
|
||||
$meta_fields['amount'] = $card_amount->decimal();
|
||||
}
|
||||
if ($type === 'credits' && $meta_fields['points'] <= 0) {
|
||||
return $this->error('积分卡积分必须大于 0');
|
||||
@@ -522,171 +617,6 @@ class PaymentController extends ControllerBase {
|
||||
]);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Rebate
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* GET /payment/rebate/log — 返佣记录列表
|
||||
*/
|
||||
public function get_rebate_list(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\Rebate')) {
|
||||
return $this->success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'max_pages' => 1]]);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
$table = \AKSRT\Core\DBMigrator::table('rebate_log');
|
||||
$page = max(1, (int) ($request->get_param('page') ?: 1));
|
||||
$per_page = max(1, min(100, (int) ($request->get_param('per_page') ?: 20)));
|
||||
$status = sanitize_key((string) $request->get_param('status'));
|
||||
$type = sanitize_key((string) $request->get_param('type'));
|
||||
$keyword = trim(sanitize_text_field((string) $request->get_param('keyword')));
|
||||
$user_id = (int) $request->get_param('user_id');
|
||||
$date_from = sanitize_text_field((string) $request->get_param('date_from'));
|
||||
$date_to = sanitize_text_field((string) $request->get_param('date_to'));
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
if ($type !== '') {
|
||||
$where[] = 'type = %s';
|
||||
$params[] = $type;
|
||||
}
|
||||
if ($user_id > 0) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
if ($date_from !== '') {
|
||||
$where[] = 'created_at >= %s';
|
||||
$params[] = $date_from . ' 00:00:00';
|
||||
}
|
||||
if ($date_to !== '') {
|
||||
$where[] = 'created_at <= %s';
|
||||
$params[] = $date_to . ' 23:59:59';
|
||||
}
|
||||
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
$sql = "SELECT * FROM {$table} {$where_sql} ORDER BY created_at DESC LIMIT 500";
|
||||
$rows = $params ? $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A) : $wpdb->get_results($sql, ARRAY_A);
|
||||
|
||||
$items = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$item = $this->build_rebate_admin_item($row);
|
||||
if ($keyword !== '' && mb_stripos(implode(' ', array_filter([
|
||||
(string) ($item['order_number'] ?? ''),
|
||||
(string) ($item['note'] ?? ''),
|
||||
(string) ($item['user']['display_name'] ?? ''),
|
||||
(string) ($item['source_user']['display_name'] ?? ''),
|
||||
])), $keyword) === false) {
|
||||
continue;
|
||||
}
|
||||
$items[] = $item;
|
||||
}
|
||||
|
||||
$total = count($items);
|
||||
$offset = ($page - 1) * $per_page;
|
||||
$stats = \AKSRT\Payment\Rebate::get_instance()->get_stats();
|
||||
|
||||
return $this->success([
|
||||
'data' => [
|
||||
'items' => array_values(array_slice($items, $offset, $per_page)),
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $per_page,
|
||||
'max_pages' => max(1, (int) ceil(max(1, $total) / $per_page)),
|
||||
'stats' => $stats,
|
||||
'options' => [
|
||||
'statuses' => [
|
||||
['label' => __('全部状态', 'aksrt'), 'value' => ''],
|
||||
['label' => __('待结算', 'aksrt'), 'value' => 'pending'],
|
||||
['label' => __('已结算', 'aksrt'), 'value' => 'settled'],
|
||||
['label' => __('已取消', 'aksrt'), 'value' => 'cancelled'],
|
||||
],
|
||||
'types' => [
|
||||
['label' => __('全部类型', 'aksrt'), 'value' => ''],
|
||||
['label' => __('订单返佣', 'aksrt'), 'value' => 'order'],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payment/rebate/{id}/settle — 结算返佣
|
||||
*/
|
||||
public function settle_rebate(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\Rebate')) {
|
||||
return $this->error('返佣系统未启用', 500);
|
||||
}
|
||||
|
||||
$id = (int) $request->get_param('id');
|
||||
$params = $this->get_json_params($request);
|
||||
$note = sanitize_textarea_field((string) ($params['note'] ?? ''));
|
||||
if ($id <= 0) {
|
||||
return $this->error('参数无效');
|
||||
}
|
||||
|
||||
$success = \AKSRT\Payment\Rebate::get_instance()->settle($id);
|
||||
|
||||
if ($success) {
|
||||
$this->append_rebate_process_log($id, 'settled', $note);
|
||||
return $this->success(['message' => __('返佣已结算', 'aksrt')]);
|
||||
}
|
||||
return $this->error(__('结算失败', 'aksrt'));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payment/rebate/{id}/cancel — 取消返佣
|
||||
*/
|
||||
public function cancel_rebate(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\Rebate')) {
|
||||
return $this->error('返佣系统未启用', 500);
|
||||
}
|
||||
|
||||
$id = (int) $request->get_param('id');
|
||||
$params = $this->get_json_params($request);
|
||||
$note = sanitize_textarea_field((string) ($params['note'] ?? ''));
|
||||
if ($id <= 0) {
|
||||
return $this->error('参数无效');
|
||||
}
|
||||
|
||||
$success = \AKSRT\Payment\Rebate::get_instance()->cancel($id);
|
||||
|
||||
if ($success) {
|
||||
$this->append_rebate_process_log($id, 'cancelled', $note);
|
||||
return $this->success(['message' => __('返佣已取消', 'aksrt')]);
|
||||
}
|
||||
return $this->error(__('操作失败', 'aksrt'));
|
||||
}
|
||||
|
||||
public function get_rebate_detail(\WP_REST_Request $request): \WP_REST_Response {
|
||||
global $wpdb;
|
||||
|
||||
$id = (int) $request->get_param('id');
|
||||
$table = \AKSRT\Core\DBMigrator::table('rebate_log');
|
||||
|
||||
if ($id <= 0 || !$table) {
|
||||
return $this->error(__('返佣记录不存在', 'aksrt'), 404);
|
||||
}
|
||||
|
||||
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $id), ARRAY_A);
|
||||
if (!$row) {
|
||||
return $this->error(__('返佣记录不存在', 'aksrt'), 404);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'data' => [
|
||||
'record' => $this->build_rebate_admin_item($row),
|
||||
'history' => $this->get_rebate_process_log($id),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function build_charge_card_admin_item(array $row): array {
|
||||
$used_by = (int) ($row['used_by'] ?? 0);
|
||||
$user = $used_by > 0 ? get_userdata($used_by) : null;
|
||||
@@ -716,18 +646,17 @@ class PaymentController extends ControllerBase {
|
||||
}
|
||||
}
|
||||
|
||||
$password = (string) ($row['password'] ?? $row['code'] ?? '');
|
||||
$password = (string) ($row['password'] ?? '');
|
||||
$card_no = (string) ($row['card'] ?? '');
|
||||
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'code' => $password,
|
||||
'password' => $password,
|
||||
'card' => $card_no,
|
||||
'type' => $type,
|
||||
'type_label' => $typeMap[$type] ?? $type,
|
||||
'amount' => (float) ($row['amount'] ?? 0),
|
||||
'balance' => (float) ($row['balance'] ?? 0),
|
||||
'amount' => $this->cash_decimal((string) ($meta['amount'] ?? $row['amount'] ?? '0')),
|
||||
'balance' => $this->cash_decimal((string) ($row['balance'] ?? '0')),
|
||||
'meta' => $meta,
|
||||
'level' => (int) ($meta['level'] ?? 0),
|
||||
'days' => (int) ($meta['days'] ?? 0),
|
||||
@@ -748,76 +677,12 @@ class PaymentController extends ControllerBase {
|
||||
];
|
||||
}
|
||||
|
||||
private function build_rebate_admin_item(array $row): array {
|
||||
$user_id = (int) ($row['user_id'] ?? 0);
|
||||
$source_user_id = (int) ($row['source_user_id'] ?? 0);
|
||||
$user = $user_id > 0 ? get_userdata($user_id) : null;
|
||||
$source_user = $source_user_id > 0 ? get_userdata($source_user_id) : null;
|
||||
$status = sanitize_key((string) ($row['status'] ?? 'pending'));
|
||||
$type = sanitize_key((string) ($row['type'] ?? 'order'));
|
||||
private function cash_decimal(string $value): string {
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return '0.00';
|
||||
}
|
||||
}
|
||||
|
||||
$statusMap = [
|
||||
'pending' => __('待结算', 'aksrt'),
|
||||
'settled' => __('已结算', 'aksrt'),
|
||||
'cancelled' => __('已取消', 'aksrt'),
|
||||
];
|
||||
$typeMap = [
|
||||
'order' => __('订单返佣', 'aksrt'),
|
||||
];
|
||||
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'order_number' => (string) ($row['order_number'] ?? ''),
|
||||
'amount' => (float) ($row['amount'] ?? 0),
|
||||
'status' => $status,
|
||||
'status_label' => $statusMap[$status] ?? $status,
|
||||
'type' => $type,
|
||||
'type_label' => $typeMap[$type] ?? $type,
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
'settled_at' => (string) ($row['settled_at'] ?? ''),
|
||||
'note' => (string) ($row['note'] ?? ''),
|
||||
'history_count' => count($this->get_rebate_process_log((int) ($row['id'] ?? 0))),
|
||||
'user' => $user instanceof \WP_User ? [
|
||||
'id' => (int) $user->ID,
|
||||
'display_name' => $user->display_name,
|
||||
'email' => $user->user_email,
|
||||
] : null,
|
||||
'source_user' => $source_user instanceof \WP_User ? [
|
||||
'id' => (int) $source_user->ID,
|
||||
'display_name' => $source_user->display_name,
|
||||
'email' => $source_user->user_email,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function append_rebate_process_log(int $rebate_id, string $event, string $note = ''): void {
|
||||
$log = get_option('aksrt_rebate_process_log', []);
|
||||
if (!is_array($log)) {
|
||||
$log = [];
|
||||
}
|
||||
|
||||
$rebate_id = (int) $rebate_id;
|
||||
if (!isset($log[$rebate_id]) || !is_array($log[$rebate_id])) {
|
||||
$log[$rebate_id] = [];
|
||||
}
|
||||
|
||||
$log[$rebate_id][] = [
|
||||
'event' => sanitize_key($event),
|
||||
'note' => sanitize_textarea_field($note),
|
||||
'operator' => get_current_user_id(),
|
||||
'operator_name' => ($user = wp_get_current_user()) ? $user->display_name : '',
|
||||
'time' => current_time('mysql'),
|
||||
];
|
||||
|
||||
update_option('aksrt_rebate_process_log', $log, false);
|
||||
}
|
||||
|
||||
private function get_rebate_process_log(int $rebate_id): array {
|
||||
$log = get_option('aksrt_rebate_process_log', []);
|
||||
if (!is_array($log) || !isset($log[$rebate_id]) || !is_array($log[$rebate_id])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values($log[$rebate_id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ class SchemaPayment {
|
||||
['key' => 'basic', 'title' => '充值开关', 'fields' => [
|
||||
['key' => 'pay_balance_s', 'label' => '启用余额功能', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'pay_balance_desc', 'label' => '充值说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'pay_balance_s', 'value' => true]],
|
||||
['key' => 'pay_auto_to_balance', 'label' => '自动到账到余额', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'pay_balance_s', 'value' => true]],
|
||||
]],
|
||||
['key' => 'packages', 'title' => '充值套餐', 'fields' => [
|
||||
['key' => 'pay_balance_product', 'label' => '套餐列表', 'type' => 'textarea', 'default' => "30|30|入门包\n98|108|推荐包", 'dependsOn' => ['key' => 'pay_balance_s', 'value' => true]],
|
||||
@@ -105,7 +104,7 @@ class SchemaPayment {
|
||||
['label' => '优惠立减', 'value' => 'reduce'],
|
||||
]],
|
||||
['key' => 'vip_renew_discount', 'label' => '续费折扣(折,如 8=八折)', 'type' => 'number', 'default' => 8, 'min' => 1, 'max' => 100, 'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'discount']],
|
||||
['key' => 'vip_renew_reduce', 'label' => '续费减免金额', 'type' => 'number', 'default' => 20, 'min' => 0, 'max' => 9999, 'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'reduce']],
|
||||
['key' => 'vip_renew_reduce', 'label' => '续费减免金额', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '20.00', 'min' => 0, 'max' => 9999, 'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'reduce']],
|
||||
]],
|
||||
['key' => 'upgrade', 'title' => '升级设置', 'fields' => [
|
||||
['key' => 'vip_upgrade', 'label' => '允许升级', 'type' => 'switch', 'default' => true],
|
||||
@@ -297,7 +296,7 @@ class SchemaPayment {
|
||||
['key' => 'redeem', 'title' => '兑换设置', 'fields' => [
|
||||
['key' => 'card_pass_max_redeem', 'label' => '单卡最大兑换次数', 'type' => 'number', 'default' => 1, 'min' => 1, 'max' => 100, 'dependsOn' => ['key' => 'card_pass_enabled', 'value' => true]],
|
||||
['key' => 'card_pass_expiry', 'label' => '有效期(天)', 'type' => 'number', 'default' => 0, 'min' => 0, 'help' => '0为永久有效', 'dependsOn' => ['key' => 'card_pass_enabled', 'value' => true]],
|
||||
['key' => 'card_pass_amount', 'label' => '面额(元)', 'type' => 'number', 'default' => 10, 'min' => 1, 'max' => 9999, 'dependsOn' => ['key' => 'card_pass_enabled', 'value' => true]],
|
||||
['key' => 'card_pass_amount', 'label' => '面额(元)', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '10.00', 'min' => 1, 'max' => 9999, 'dependsOn' => ['key' => 'card_pass_enabled', 'value' => true]],
|
||||
['key' => 'card_pass_vip_days', 'label' => 'VIP天数', 'type' => 'number', 'default' => 30, 'min' => 1, 'max' => 3650, 'dependsOn' => ['key' => 'card_pass_enabled', 'value' => true]],
|
||||
['key' => 'card_pass_credits', 'label' => '赠送积分', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 99999, 'dependsOn' => ['key' => 'card_pass_enabled', 'value' => true]],
|
||||
]],
|
||||
@@ -315,10 +314,10 @@ class SchemaPayment {
|
||||
'option_key' => 'aksrt_settings',
|
||||
'groups' => [
|
||||
['key' => 'basic', 'title' => '提现设置', 'fields' => [
|
||||
['key' => 'affiliate_min_withdrawal', 'label' => '最低提现金额', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 99999],
|
||||
['key' => 'affiliate_min_withdrawal', 'label' => '最低提现金额', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '50.00', 'desc' => '仅接受最多两位小数的十进制字符串。'],
|
||||
['key' => 'affiliate_withdrawal_fee', 'label' => '提现手续费(%)', 'type' => 'number', 'default' => 5, 'min' => 0, 'max' => 100],
|
||||
['key' => 'affiliate_daily_withdrawal_count', 'label' => '每日可提现次数', 'type' => 'number', 'default' => 3, 'min' => 0, 'max' => 99999],
|
||||
['key' => 'affiliate_daily_withdrawal_limit', 'label' => '每日提现上限', 'type' => 'number', 'default' => 500, 'min' => 0, 'max' => 999999],
|
||||
['key' => 'affiliate_daily_withdrawal_limit', 'label' => '每日提现上限', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '500.00', 'desc' => '仅接受最多两位小数的十进制字符串。'],
|
||||
['key' => 'affiliate_withdrawal_reset_time', 'label' => '重置时间', 'type' => 'input', 'default' => '00:00', 'help' => '按站点 WordPress 时区重置,格式 HH:MM。'],
|
||||
['key' => 'affiliate_payout_channels', 'label' => '可用收款渠道', 'type' => 'checkbox', 'options' => [['value' => 'alipay', 'label' => '支付宝收款码'], ['value' => 'wechat', 'label' => '微信收款码']], 'default' => ['alipay', 'wechat']],
|
||||
]],
|
||||
|
||||
@@ -114,8 +114,8 @@ class SchemaPostList {
|
||||
['key' => 'post_rewards_text', 'label' => '打赏按钮文案', 'type' => 'input', 'default' => '赞赏'],
|
||||
['key' => 'post_rewards_amounts', 'label' => '快捷金额', 'type' => 'input', 'default' => '5,10,20,50,100', 'description' => '英文逗号分隔,赞赏直接进入作者站内余额'],
|
||||
['key' => 'post_rewards_custom', 'label' => '允许自定义金额', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'post_rewards_min', 'label' => '最低金额', 'type' => 'number', 'default' => 1, 'min' => 0.01, 'max' => 1000, 'step' => 0.01],
|
||||
['key' => 'post_rewards_max', 'label' => '最高金额', 'type' => 'number', 'default' => 1000, 'min' => 1, 'max' => 100000, 'step' => 0.01],
|
||||
['key' => 'post_rewards_min', 'label' => '最低金额', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '1.00', 'min' => 0.01, 'max' => 1000, 'step' => 0.01],
|
||||
['key' => 'post_rewards_max', 'label' => '最高金额', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '1000.00', 'min' => 1, 'max' => 100000, 'step' => 0.01],
|
||||
['key' => 'post_button_toptext', 'label' => '按钮上方文案', 'type' => 'input', 'default' => '喜欢就支持一下吧'],
|
||||
]],
|
||||
['key' => 'content', 'title' => '内容处理', 'fields' => [
|
||||
@@ -346,8 +346,8 @@ class SchemaPostList {
|
||||
]],
|
||||
['key' => 'paid', 'title' => '付费投稿权限', 'fields' => [
|
||||
['key' => 'post_article_pay_free_s', 'label' => '允许设置免费', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'post_article_pay_price_min', 'label' => '最低价格', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'help' => '投稿可设置的最低付费金额'],
|
||||
['key' => 'post_article_pay_price_max', 'label' => '最高价格', 'type' => 'number', 'default' => 100, 'min' => 0, 'max' => 9999, 'help' => '投稿可设置的最高付费金额'],
|
||||
['key' => 'post_article_pay_price_min', 'label' => '最低价格', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '0.00', 'min' => 0, 'max' => 9999, 'help' => '投稿可设置的最低付费金额'],
|
||||
['key' => 'post_article_pay_price_max', 'label' => '最高价格', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '100.00', 'min' => 0, 'max' => 9999, 'help' => '投稿可设置的最高付费金额'],
|
||||
['key' => 'post_article_pay_vip_price_s', 'label' => '允许设置会员价', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'post_article_pay_vip_1_discount', 'label' => 'VIP1 折扣(%)', 'type' => 'number', 'default' => 80, 'min' => 0, 'max' => 100, 'help' => 'VIP1 会员折扣百分比,80 即 8 折'],
|
||||
['key' => 'post_article_pay_vip_2_discount', 'label' => 'VIP2 折扣(%)', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 100, 'help' => 'VIP2 会员折扣百分比,50 即 5 折'],
|
||||
|
||||
@@ -514,9 +514,9 @@ class SchemaUsers {
|
||||
['key' => 'points_deduction_max', 'label' => '最大抵扣比例(%)', 'type' => 'number', 'default' => 50, 'min' => 1, 'max' => 100, 'help' => '预留:最多可抵订单金额百分比。当前未参与下单。'],
|
||||
]],
|
||||
['key' => 'card_pass', 'title' => '积分卡密兑换', 'fields' => [
|
||||
['key' => 'points_pass_exchange_s', 'label' => '启用积分卡密兑换', 'type' => 'switch', 'default' => false],
|
||||
['key' => 'points_pass_exchange_desc', 'label' => '兑换说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'points_pass_exchange_s', 'value' => true]],
|
||||
['key' => 'points_pass_exchange_only_password', 'label' => '仅密码兑换', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'points_pass_exchange_s', 'value' => true]],
|
||||
['key' => 'asset_points_card_enabled', 'label' => '启用积分卡密兑换', 'type' => 'switch', 'default' => false],
|
||||
['key' => 'asset_points_card_description', 'label' => '兑换说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'asset_points_card_enabled', 'value' => true]],
|
||||
['key' => 'asset_points_card_password_only', 'label' => '仅密码兑换', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'asset_points_card_enabled', 'value' => true]],
|
||||
]],
|
||||
['key' => 'display', 'title' => '展示设置', 'fields' => [
|
||||
['key' => 'points_show_balance', 'label' => '导航栏显示余额', 'type' => 'switch', 'default' => true],
|
||||
@@ -591,9 +591,9 @@ class SchemaUsers {
|
||||
['key' => 'apply', 'title' => '申请设置', 'fields' => [
|
||||
['key' => 'user_verify_allow_apply', 'label' => '允许用户前台申请', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'user_verify_disable_desc', 'label' => '停用申请说明', 'type' => 'textarea', 'default' => "由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员"],
|
||||
['key' => 'user_verify_personal_fee', 'label' => '个人认证每次扣余额(元)', 'type' => 'number', 'sanitize_type' => 'float', 'default' => 0, 'min' => 0, 'step' => 0.01],
|
||||
['key' => 'user_verify_personal_fee', 'label' => '个人认证每次扣余额(元)', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '0.00', 'desc' => '仅接受最多两位小数的十进制字符串。'],
|
||||
['key' => 'user_verify_personal_free_applications', 'label' => '个人认证赠送初始次数', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'desc' => '每位用户前 N 次个人认证申请免费,之后按单次费用扣余额。'],
|
||||
['key' => 'user_verify_enterprise_fee', 'label' => '企业认证每次扣余额(元)', 'type' => 'number', 'sanitize_type' => 'float', 'default' => 0, 'min' => 0, 'step' => 0.01],
|
||||
['key' => 'user_verify_enterprise_fee', 'label' => '企业认证每次扣余额(元)', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '0.00', 'desc' => '仅接受最多两位小数的十进制字符串。'],
|
||||
['key' => 'user_verify_enterprise_free_applications', 'label' => '企业认证赠送初始次数', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'desc' => '每位用户前 N 次企业认证申请免费,之后按单次费用扣余额。'],
|
||||
['key' => 'user_verify_desc', 'label' => '申请介绍', 'type' => 'textarea', 'default' => '认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。'],
|
||||
]],
|
||||
|
||||
@@ -170,6 +170,17 @@ class SchemaProvider {
|
||||
$errors[] = sprintf('%s is required', $field['label'] ?? $key);
|
||||
continue;
|
||||
}
|
||||
if (($field['sanitize_type'] ?? '') === 'money_decimal') {
|
||||
try {
|
||||
if (!is_string($value)) {
|
||||
throw new \InvalidArgumentException('money_decimal_must_be_string');
|
||||
}
|
||||
\AKSRT\Ledger\AssetAmount::balance(trim($value));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$errors[] = sprintf('%s must be a decimal string with at most two fractional digits', $field['label'] ?? $key);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$type = $field['type'] ?? 'input';
|
||||
if (($type === 'number' || $type === 'slider') && !is_numeric($value)) {
|
||||
@@ -259,13 +270,20 @@ class SchemaProvider {
|
||||
$stored = get_option('aksrt_settings', []);
|
||||
$merged = is_array($stored) ? array_merge($stored, $data) : $data;
|
||||
$count = $merged['affiliate_daily_withdrawal_count'] ?? 3;
|
||||
$min = (float) ($merged['affiliate_min_withdrawal'] ?? 50);
|
||||
$limit = (float) ($merged['affiliate_daily_withdrawal_limit'] ?? 500);
|
||||
$min_raw = $merged['affiliate_min_withdrawal'] ?? '50.00';
|
||||
$limit_raw = $merged['affiliate_daily_withdrawal_limit'] ?? '500.00';
|
||||
$time = (string) ($merged['affiliate_withdrawal_reset_time'] ?? '00:00');
|
||||
if (filter_var($count, FILTER_VALIDATE_INT) === false || (int) $count < 0) {
|
||||
$errors[] = __('每日可提现次数必须是非负整数', 'aksrt');
|
||||
}
|
||||
if ($limit < $min) {
|
||||
try {
|
||||
$min_minor = \AKSRT\Ledger\AssetAmount::balance(is_string($min_raw) ? $min_raw : '')->minor();
|
||||
$limit_minor = \AKSRT\Ledger\AssetAmount::balance(is_string($limit_raw) ? $limit_raw : '')->minor();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$min_minor = 0;
|
||||
$limit_minor = 0;
|
||||
}
|
||||
if ($limit_minor < $min_minor) {
|
||||
$errors[] = __('每日提现上限不得低于最低提现金额', 'aksrt');
|
||||
}
|
||||
if (preg_match('/^(?:[01]\\d|2[0-3]):[0-5]\\d$/', $time) !== 1) {
|
||||
@@ -463,6 +481,15 @@ class SchemaProvider {
|
||||
return !empty($value);
|
||||
case 'float':
|
||||
return (float) $value;
|
||||
case 'money_decimal':
|
||||
if (!is_string($value)) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance(trim($value))->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return '';
|
||||
}
|
||||
case 'array':
|
||||
// 深层字符串净化,保留嵌套服务商配置(如 geetest.captcha_id)
|
||||
if (!is_array($value)) {
|
||||
|
||||
+139
-33
@@ -108,39 +108,60 @@ class UserController extends ControllerBase {
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Credits settings (admin)
|
||||
register_rest_route(self::API_NAMESPACE, '/credits/settings', [
|
||||
register_rest_route(self::API_NAMESPACE, '/assets/settings', [
|
||||
'methods' => ['GET', 'POST'],
|
||||
'callback' => [$this, 'rest_handle_credit_settings'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Credits — current user balance (frontend)
|
||||
register_rest_route(self::API_NAMESPACE, '/credits/me', [
|
||||
register_rest_route(self::API_NAMESPACE, '/assets/me', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_get_credits_me'],
|
||||
'permission_callback' => function () { return is_user_logged_in(); },
|
||||
'permission_callback' => [$this, 'check_asset_self_permission'],
|
||||
]);
|
||||
|
||||
// Credits — user list (admin)
|
||||
register_rest_route(self::API_NAMESPACE, '/credits/users', [
|
||||
register_rest_route(self::API_NAMESPACE, '/assets/accounts', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_list_credit_users'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Credits — adjust user balance (admin)
|
||||
register_rest_route(self::API_NAMESPACE, '/credits/users/(?P<id>\d+)/adjust', [
|
||||
register_rest_route(self::API_NAMESPACE, '/assets/accounts/(?P<id>\d+)/adjust', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_adjust_user_credits'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'id' => ['type' => 'integer', 'minimum' => 1, 'required' => true],
|
||||
'asset' => ['type' => 'string', 'enum' => ['points', 'balance'], 'required' => true],
|
||||
'delta' => [
|
||||
'required' => true,
|
||||
'oneOf' => [
|
||||
['type' => 'integer'],
|
||||
['type' => 'string', 'pattern' => '^-?(?:0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
],
|
||||
],
|
||||
'remark' => ['type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Credits — transaction log (admin)
|
||||
register_rest_route(self::API_NAMESPACE, '/credits/log', [
|
||||
register_rest_route(self::API_NAMESPACE, '/assets/transactions', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_get_credits_log'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'transaction_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'operation_key' => ['type' => 'string', 'maxLength' => 191, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'user_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'actor_user_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'asset' => ['type' => 'string', 'enum' => ['points', 'balance']],
|
||||
'reason' => ['type' => 'string', 'maxLength' => 64, 'sanitize_callback' => 'sanitize_key'],
|
||||
'ref_type' => ['type' => 'string', 'maxLength' => 32, 'sanitize_callback' => 'sanitize_key'],
|
||||
'ref_key' => ['type' => 'string', 'maxLength' => 191, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
|
||||
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20],
|
||||
'date_from' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'date_to' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Report ticket list (admin)
|
||||
@@ -639,60 +660,145 @@ class UserController extends ControllerBase {
|
||||
$user_id = get_current_user_id();
|
||||
return $this->success(['data' => [
|
||||
'user_id' => $user_id,
|
||||
'balance' => class_exists('\AKSRT\Credits\Credits') ? \AKSRT\Credits\Credits::balance($user_id) : 0,
|
||||
'points' => \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
]]);
|
||||
}
|
||||
|
||||
public function check_asset_self_permission(\WP_REST_Request $request) {
|
||||
if (!is_user_logged_in()) {
|
||||
return new \WP_Error('aksrt_auth_required', __('请先登录', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
$nonce = (string) $request->get_header('X-WP-Nonce');
|
||||
if ($nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) {
|
||||
return new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rest_list_credit_users(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$args = [
|
||||
'search' => (string) $request->get_param('search'),
|
||||
'page' => (int) $request->get_param('page'),
|
||||
'per_page' => (int) $request->get_param('per_page'),
|
||||
];
|
||||
$data = class_exists('\AKSRT\Credits\Credits') ? \AKSRT\Credits\Credits::list_users($args) : ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 20];
|
||||
$asset = sanitize_key((string) $request->get_param('asset'));
|
||||
$asset = in_array($asset, ['points', 'balance'], true) ? $asset : 'points';
|
||||
$data = \AKSRT\Ledger\LedgerQuery::get_instance()->list_accounts($asset, $args);
|
||||
foreach ($data['items'] as &$item) {
|
||||
$item['asset'] = $asset;
|
||||
$item['balance'] = $asset === 'balance'
|
||||
? \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, (int) $item['balance_minor'])->decimal()
|
||||
: (int) $item['balance_minor'];
|
||||
unset($item['balance_minor']);
|
||||
}
|
||||
unset($item);
|
||||
return $this->success(['data' => $data]);
|
||||
}
|
||||
|
||||
public function rest_adjust_user_credits(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Credits\Credits')) {
|
||||
return $this->error(__('积分系统不可用', 'aksrt'), 500);
|
||||
}
|
||||
|
||||
$user_id = (int) $request->get_param('id');
|
||||
$params = $this->get_json_params($request);
|
||||
$delta = (int) ($params['delta'] ?? 0);
|
||||
$reason = sanitize_text_field((string) ($params['reason'] ?? 'admin'));
|
||||
|
||||
if ($user_id <= 0 || $delta === 0) {
|
||||
return $this->error(__('用户或积分变动值无效', 'aksrt'), 422);
|
||||
}
|
||||
|
||||
if ($delta > 0) {
|
||||
$result = \AKSRT\Credits\Credits::add($user_id, $delta, $reason, ['type' => 'user', 'id' => $user_id], 'Admin adjustment');
|
||||
} else {
|
||||
$result = \AKSRT\Credits\Credits::deduct($user_id, abs($delta), $reason, ['type' => 'user', 'id' => $user_id], 'Admin adjustment');
|
||||
}
|
||||
$asset = sanitize_key((string) ($params['asset'] ?? ''));
|
||||
$raw_delta = $params['delta'] ?? null;
|
||||
$remark = sanitize_text_field((string) ($params['remark'] ?? ''));
|
||||
if ($user_id <= 0 || !in_array($asset, ['points', 'balance'], true) || !get_userdata($user_id)) {
|
||||
return $this->error(__('用户或资产类型无效', 'aksrt'), 400);
|
||||
}
|
||||
if (!current_user_can('edit_user', $user_id)) {
|
||||
return $this->error(__('无权调整该用户资产', 'aksrt'), 403);
|
||||
}
|
||||
$negative = is_string($raw_delta) && str_starts_with($raw_delta, '-');
|
||||
$unsigned = $negative ? substr($raw_delta, 1) : $raw_delta;
|
||||
try {
|
||||
if ($asset === 'points') {
|
||||
if (!is_int($raw_delta) || $raw_delta === PHP_INT_MIN) throw new \InvalidArgumentException();
|
||||
$minor = abs($raw_delta);
|
||||
$negative = $raw_delta < 0;
|
||||
} else {
|
||||
if (!is_string($unsigned)) throw new \InvalidArgumentException();
|
||||
$minor = \AKSRT\Ledger\AssetAmount::balance($unsigned)->minor();
|
||||
}
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error(__('资产变动金额格式无效', 'aksrt'), 400);
|
||||
}
|
||||
if ($minor <= 0) return $this->error(__('资产变动金额必须非零', 'aksrt'), 400);
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-adjustment', get_current_user_id());
|
||||
if (is_wp_error($operation_key)) return new \WP_REST_Response(['success' => false, 'message' => $operation_key->get_error_message()], 400);
|
||||
$service = \AKSRT\Ledger\LedgerService::get_instance();
|
||||
$result = $negative
|
||||
? $service->debit($user_id, $asset, $minor, $operation_key, 'admin_adjustment', ['type' => 'user', 'id' => $user_id], $remark, get_current_user_id())
|
||||
: $service->credit($user_id, $asset, $minor, $operation_key, 'admin_adjustment', ['type' => 'user', 'id' => $user_id], $remark, get_current_user_id());
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return $this->error($result->get_error_message(), 422);
|
||||
$status = (int) (($result->get_error_data()['status'] ?? 400));
|
||||
return $this->error($result->get_error_message(), $status);
|
||||
}
|
||||
|
||||
return $this->success(['data' => [
|
||||
'user_id' => $user_id,
|
||||
'balance' => (int) $result,
|
||||
], 'message' => __('积分调整已保存', 'aksrt')]);
|
||||
'asset' => $asset,
|
||||
'balance' => $asset === 'balance'
|
||||
? \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, $asset))->decimal()
|
||||
: $result->balance($user_id, $asset),
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
], 'message' => __('资产调整已保存', 'aksrt')]);
|
||||
}
|
||||
|
||||
public function rest_get_credits_log(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$args = [
|
||||
'transaction_id' => (int) $request->get_param('transaction_id'),
|
||||
'operation_key' => (string) $request->get_param('operation_key'),
|
||||
'user_id' => (int) $request->get_param('user_id'),
|
||||
'actor_user_id' => (int) $request->get_param('actor_user_id'),
|
||||
'reason' => (string) $request->get_param('reason'),
|
||||
'ref_type' => (string) $request->get_param('ref_type'),
|
||||
'ref_key' => (string) $request->get_param('ref_key'),
|
||||
'page' => (int) $request->get_param('page'),
|
||||
'per_page' => (int) $request->get_param('per_page'),
|
||||
'date_from' => (string) $request->get_param('date_from'),
|
||||
'date_to' => (string) $request->get_param('date_to'),
|
||||
];
|
||||
$data = class_exists('\AKSRT\Credits\Credits') ? \AKSRT\Credits\Credits::get_log($args) : ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 20];
|
||||
$args['asset'] = sanitize_key((string) $request->get_param('asset'));
|
||||
$data = \AKSRT\Ledger\LedgerQuery::get_instance()->history($args);
|
||||
foreach ($data['items'] as &$item) {
|
||||
$assets = array_values(array_unique(array_map('strval', (array) ($item['assets'] ?? []))));
|
||||
$single_asset = count($assets) === 1 ? $assets[0] : '';
|
||||
if ($single_asset !== '') {
|
||||
$item['delta'] = $this->asset_minor_for_response($single_asset, (int) ($item['delta_minor'] ?? 0), true);
|
||||
$item['fee'] = $this->asset_minor_for_response($single_asset, (int) ($item['fee_minor'] ?? 0));
|
||||
$item['balance_after'] = $this->asset_minor_for_response($single_asset, (int) ($item['balance_after_minor'] ?? 0));
|
||||
} else {
|
||||
$item['delta'] = null;
|
||||
$item['fee'] = null;
|
||||
$item['balance_after'] = null;
|
||||
}
|
||||
unset($item['delta_minor'], $item['fee_minor'], $item['balance_after_minor']);
|
||||
foreach ($item['entries'] as &$entry) {
|
||||
$entry_asset = (string) ($entry['asset'] ?? '');
|
||||
$entry['delta'] = $this->asset_minor_for_response($entry_asset, (int) ($entry['delta_minor'] ?? 0), true);
|
||||
$entry['balance_after'] = $this->asset_minor_for_response($entry_asset, (int) ($entry['balance_after_minor'] ?? 0));
|
||||
unset($entry['delta_minor'], $entry['balance_after_minor']);
|
||||
}
|
||||
unset($entry);
|
||||
}
|
||||
unset($item);
|
||||
return $this->success(['data' => $data]);
|
||||
}
|
||||
|
||||
private function asset_minor_for_response(string $asset, int $minor, bool $signed = false) {
|
||||
if ($asset === \AKSRT\Ledger\AssetType::POINTS->value) {
|
||||
return $minor;
|
||||
}
|
||||
if ($asset !== \AKSRT\Ledger\AssetType::BALANCE->value) {
|
||||
return null;
|
||||
}
|
||||
$negative = $minor < 0;
|
||||
$value = \AKSRT\Ledger\AssetAmount::decimal_from_minor(abs($minor));
|
||||
if ($negative) {
|
||||
return '-' . $value;
|
||||
}
|
||||
return $signed && $minor > 0 ? '+' . $value : $value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,9 @@ class Download {
|
||||
$pay_modo = is_array($priced)
|
||||
? ((($priced['modo'] ?? '0') === 'points') ? 'points' : '0')
|
||||
: ((($meta['pay_modo'] ?? '0') === 'points') ? 'points' : '0');
|
||||
$display_price = is_array($priced) ? (float) ($priced['amount'] ?? 0) : (float) ($meta['price'] ?? 0);
|
||||
$display_price = is_array($priced)
|
||||
? ($priced['amount'] ?? ($pay_modo === 'points' ? 0 : '0.00'))
|
||||
: ($pay_modo === 'points' ? (int) ($meta['points_price'] ?? 0) : (string) ($meta['price'] ?? '0.00'));
|
||||
|
||||
$access = class_exists('AKSRT\\Content\\Visibility')
|
||||
? Visibility::get_post_access_state($post, $uid)
|
||||
@@ -135,14 +137,14 @@ class Download {
|
||||
return new \WP_REST_Response([
|
||||
'post_id' => $post_id,
|
||||
'price' => $display_price,
|
||||
'base_price' => is_array($priced) ? (float) ($priced['base'] ?? $display_price) : (float) ($meta['price'] ?? 0),
|
||||
'base_price' => is_array($priced) ? ($priced['base'] ?? $display_price) : $display_price,
|
||||
'pay_modo' => $pay_modo,
|
||||
'pay_limit' => (int) ($meta['pay_limit'] ?? 0),
|
||||
'cost' => $cost,
|
||||
'require_login' => !empty($access['need_login']),
|
||||
'require_captcha' => $need_captcha,
|
||||
'logged_in' => $uid > 0,
|
||||
'balance' => $uid > 0 && class_exists('\AKSRT\Credits\Credits') ? \AKSRT\Credits\Credits::balance($uid) : 0,
|
||||
'balance' => $uid > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($uid, \AKSRT\Ledger\AssetType::POINTS) : 0,
|
||||
'has_paid_access' => !empty($access['has_paid_access']),
|
||||
'need_purchase' => !empty($access['need_purchase']),
|
||||
'need_vip' => !empty($access['need_vip']),
|
||||
@@ -242,7 +244,7 @@ class Download {
|
||||
'source_type' => $source['type'] ?? 'direct',
|
||||
'has_extract' => $extract_code !== '',
|
||||
'extract_code' => $extract_code,
|
||||
'balance' => $uid > 0 && class_exists('\AKSRT\Credits\Credits') ? \AKSRT\Credits\Credits::balance($uid) : 0,
|
||||
'balance' => $uid > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($uid, \AKSRT\Ledger\AssetType::POINTS) : 0,
|
||||
], 200);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class DownloadState {
|
||||
* @return array{
|
||||
* state:string,can_download:bool,message:string,action_label:string,
|
||||
* action_url:string,cost:int,balance:int,login_url:string,pay_modo:string,
|
||||
* amount:float,need_purchase:bool,need_vip:bool,has_paid_access:bool
|
||||
* amount:int|string,need_purchase:bool,need_vip:bool,has_paid_access:bool
|
||||
* }
|
||||
*/
|
||||
public function resolve(int $post_id): array {
|
||||
@@ -72,13 +72,14 @@ class DownloadState {
|
||||
$pay_modo = is_array($priced)
|
||||
? ((($priced['modo'] ?? '0') === 'points') ? 'points' : '0')
|
||||
: '0';
|
||||
$amount = is_array($priced) ? (float) ($priced['amount'] ?? 0) : 0.0;
|
||||
$amount = is_array($priced)
|
||||
? ($priced['amount'] ?? ($pay_modo === 'points' ? 0 : '0.00'))
|
||||
: ($pay_modo === 'points' ? 0 : '0.00');
|
||||
$amount_minor = $this->amount_minor($amount, $pay_modo);
|
||||
$cost = function_exists('aksrt_resource_download_cost')
|
||||
? (int) aksrt_resource_download_cost($post_id)
|
||||
: 0;
|
||||
$balance = ($user_id > 0 && class_exists('\AKSRT\Credits\Credits'))
|
||||
? \AKSRT\Credits\Credits::balance($user_id)
|
||||
: 0;
|
||||
$balance = $user_id > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS) : 0;
|
||||
|
||||
$gate = function_exists('aksrt_can_download') ? aksrt_can_download($post_id, $user_id) : true;
|
||||
if (is_wp_error($gate)) {
|
||||
@@ -130,9 +131,9 @@ class DownloadState {
|
||||
}
|
||||
|
||||
// need_purchase / legacy insufficient credits → cashier.
|
||||
if (in_array($code, ['aksrt_need_purchase', 'aksrt_credits_insufficient'], true)) {
|
||||
if (in_array($code, ['aksrt_need_purchase', 'aksrt_points_insufficient'], true)) {
|
||||
$label = $pay_modo === 'points'
|
||||
? sprintf(__('购买(%s 积分)', 'aksrt'), (string) (int) ceil($amount > 0 ? $amount : $cost))
|
||||
? sprintf(__('购买(%s 积分)', 'aksrt'), (string) ($amount_minor > 0 ? $amount_minor : $cost))
|
||||
: __('去购买', 'aksrt');
|
||||
return $this->build_result(
|
||||
self::STATE_NEED_PURCHASE,
|
||||
@@ -145,7 +146,7 @@ class DownloadState {
|
||||
$login_url,
|
||||
[
|
||||
'pay_modo' => $pay_modo,
|
||||
'amount' => $amount > 0 ? $amount : (float) $cost,
|
||||
'amount' => $amount_minor > 0 ? $amount : ($pay_modo === 'points' ? $cost : '0.00'),
|
||||
'need_purchase' => true,
|
||||
'need_vip' => false,
|
||||
'has_paid_access' => false,
|
||||
@@ -174,8 +175,8 @@ class DownloadState {
|
||||
|
||||
// Paid / free unlock.
|
||||
$has_paid = !empty($access['has_paid_access']);
|
||||
if ($has_paid || $amount <= 0) {
|
||||
$owned = $has_paid && $amount > 0;
|
||||
if ($has_paid || $amount_minor <= 0) {
|
||||
$owned = $has_paid && $amount_minor > 0;
|
||||
return $this->build_result(
|
||||
$owned ? self::STATE_OWNED : self::STATE_AVAILABLE,
|
||||
true,
|
||||
@@ -189,7 +190,7 @@ class DownloadState {
|
||||
$login_url,
|
||||
[
|
||||
'pay_modo' => $pay_modo,
|
||||
'amount' => 0.0,
|
||||
'amount' => $pay_modo === 'points' ? 0 : '0.00',
|
||||
'need_purchase' => false,
|
||||
'need_vip' => false,
|
||||
'has_paid_access' => true,
|
||||
@@ -241,10 +242,24 @@ class DownloadState {
|
||||
'balance' => $balance,
|
||||
'login_url' => $login_url,
|
||||
'pay_modo' => '0',
|
||||
'amount' => 0.0,
|
||||
'amount' => '0.00',
|
||||
'need_purchase' => false,
|
||||
'need_vip' => false,
|
||||
'has_paid_access' => false,
|
||||
], $extra);
|
||||
}
|
||||
|
||||
private function amount_minor($value, string $pay_modo): int {
|
||||
if ($pay_modo === 'points') {
|
||||
return is_int($value) ? max(0, $value) : 0;
|
||||
}
|
||||
if (!is_string($value)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance_minor($value);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ class Pay {
|
||||
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
add_action('aksrt_order_paid', [$this, 'on_order_paid'], 15, 2);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+133
-93
@@ -31,34 +31,35 @@ trait PayHelpers {
|
||||
$uid = $user_id > 0 ? $user_id : get_current_user_id();
|
||||
$priced = function_exists('aksrt_get_user_pay_price')
|
||||
? aksrt_get_user_pay_price($post, $uid)
|
||||
: ['amount' => 0.0, 'base' => 0.0, 'modo' => '0', 'currency' => '¥', 'is_free' => true, 'pay_limit' => 0, 'vip_level' => 0, 'original' => 0.0, 'vip1' => 0.0, 'vip2' => 0.0];
|
||||
: ['amount' => '0.00', 'base' => '0.00', 'modo' => '0', 'currency' => '¥', 'is_free' => true, 'pay_limit' => 0, 'vip_level' => 0, 'original' => '0.00', 'vip1' => '0.00', 'vip2' => '0.00'];
|
||||
|
||||
$pay_modo = (($priced['modo'] ?? '0') === 'points') ? 'points' : '0';
|
||||
|
||||
// Optional overbuy override for preview display.
|
||||
if ($price_type === 'download_limit_over') {
|
||||
$over = max(0.0, (float) ($meta['download_limit_over_price'] ?? 0));
|
||||
if ($over > 0) {
|
||||
$priced['amount'] = $over;
|
||||
$priced['base'] = $over;
|
||||
$over_minor = $this->parse_asset_minor($meta['download_limit_over_price'] ?? ($pay_modo === 'points' ? 0 : '0'), $pay_modo);
|
||||
if ($over_minor > 0) {
|
||||
$priced['amount'] = $this->asset_value($over_minor, $pay_modo);
|
||||
$priced['base'] = $this->asset_value($over_minor, $pay_modo);
|
||||
$priced['is_free'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
$pay_modo = (($priced['modo'] ?? '0') === 'points') ? 'points' : '0';
|
||||
$pay_limit = (int) ($priced['pay_limit'] ?? 0);
|
||||
$vip_level = (int) ($priced['vip_level'] ?? 0);
|
||||
$amount = (float) ($priced['amount'] ?? 0);
|
||||
$base = (float) ($priced['base'] ?? $amount);
|
||||
$over_price = max(0.0, (float) ($meta['download_limit_over_price'] ?? 0));
|
||||
$amount_minor = $this->parse_asset_minor($priced['amount'] ?? ($pay_modo === 'points' ? 0 : '0'), $pay_modo);
|
||||
$base_minor = $this->parse_asset_minor($priced['base'] ?? $priced['amount'] ?? ($pay_modo === 'points' ? 0 : '0'), $pay_modo);
|
||||
$over_minor = $this->parse_asset_minor($meta['download_limit_over_price'] ?? ($pay_modo === 'points' ? 0 : '0'), $pay_modo);
|
||||
|
||||
$list_amount = $amount;
|
||||
$rebate_discount = 0.0;
|
||||
$coupon_discount = 0.0;
|
||||
$list_minor = $amount_minor;
|
||||
$rebate_discount_minor = 0;
|
||||
$coupon_discount_minor = 0;
|
||||
$coupon_payload = null;
|
||||
$coupon_error = '';
|
||||
$referrer_active = false;
|
||||
$referrer_id = 0;
|
||||
$rebate_price = 0.0;
|
||||
$payable = $amount;
|
||||
$rebate_price_minor = 0;
|
||||
$payable_minor = $amount_minor;
|
||||
|
||||
$coupon_allowed = $pay_modo !== 'points' && $this->is_coupon_allowed($post_id, $meta);
|
||||
$coupon_desc = '';
|
||||
@@ -77,15 +78,15 @@ trait PayHelpers {
|
||||
$discounted = $this->apply_cash_discounts($priced, $post, $meta, $uid, '', true);
|
||||
}
|
||||
if (!is_wp_error($discounted)) {
|
||||
$list_amount = (float) ($discounted['list_amount'] ?? $amount);
|
||||
$rebate_discount = (float) ($discounted['rebate_discount'] ?? 0);
|
||||
$coupon_discount = (float) ($discounted['coupon_discount'] ?? 0);
|
||||
$list_minor = (int) ($discounted['list_minor'] ?? $amount_minor);
|
||||
$rebate_discount_minor = (int) ($discounted['rebate_discount_minor'] ?? 0);
|
||||
$coupon_discount_minor = (int) ($discounted['coupon_discount_minor'] ?? 0);
|
||||
$coupon_payload = $discounted['coupon'] ?? null;
|
||||
$payable = (float) ($discounted['payable'] ?? $amount);
|
||||
$payable_minor = (int) ($discounted['payable_minor'] ?? $amount_minor);
|
||||
$referrer_id = (int) ($discounted['referrer_id'] ?? 0);
|
||||
$referrer_active = $referrer_id > 0;
|
||||
$rebate_price = (float) ($discounted['rebate_price'] ?? 0);
|
||||
$amount = $payable;
|
||||
$rebate_price_minor = (int) ($discounted['rebate_price_minor'] ?? 0);
|
||||
$amount_minor = $payable_minor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,19 +122,16 @@ trait PayHelpers {
|
||||
: '¥'));
|
||||
|
||||
// Points cashier uses Credits; cash cashier uses wallet (元). Keep both for UI.
|
||||
$points_balance = ($uid > 0 && class_exists('\AKSRT\Credits\Credits'))
|
||||
? (int) \AKSRT\Credits\Credits::balance($uid)
|
||||
: 0;
|
||||
$wallet_balance = ($uid > 0 && class_exists('\AKSRT\Credits\Balance') && \AKSRT\Credits\Balance::enabled())
|
||||
? (float) \AKSRT\Credits\Balance::balance($uid)
|
||||
: 0.0;
|
||||
$points_balance = $uid > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($uid, \AKSRT\Ledger\AssetType::POINTS) : 0;
|
||||
$wallet_minor = $uid > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($uid, \AKSRT\Ledger\AssetType::BALANCE) : 0;
|
||||
$wallet_balance = \AKSRT\Ledger\AssetAmount::decimal_from_minor($wallet_minor);
|
||||
$balance = $pay_modo === 'points' ? $points_balance : $wallet_balance;
|
||||
$methods = $this->available_payment_methods($pay_modo, $uid > 0);
|
||||
|
||||
$amount_label = $this->format_amount_label($amount, $pay_modo, $currency);
|
||||
$base_label = $this->format_amount_label($base, $pay_modo, $currency);
|
||||
$list_label = $this->format_amount_label($list_amount, $pay_modo, $currency);
|
||||
$over_label = $over_price > 0 ? $this->format_amount_label($over_price, $pay_modo, $currency) : '';
|
||||
$amount_label = $this->format_amount_label($amount_minor, $pay_modo, $currency);
|
||||
$base_label = $this->format_amount_label($base_minor, $pay_modo, $currency);
|
||||
$list_label = $this->format_amount_label($list_minor, $pay_modo, $currency);
|
||||
$over_label = $over_minor > 0 ? $this->format_amount_label($over_minor, $pay_modo, $currency) : '';
|
||||
|
||||
$vip_page = function_exists('aksrt_get_user_center_tab_url')
|
||||
? aksrt_get_user_center_tab_url('credits')
|
||||
@@ -147,10 +145,10 @@ trait PayHelpers {
|
||||
? aksrt_get_user_free_downloaded_number($uid) : 0;
|
||||
$quota_over = $free_limit > 0 && $free_used >= $free_limit;
|
||||
|
||||
$payable_amount = $pay_modo === 'points' ? (int) ceil($payable) : round($payable, 2);
|
||||
$payable_amount = $this->asset_value($payable_minor, $pay_modo);
|
||||
$balance_enough = $pay_modo === 'points'
|
||||
? ($points_balance >= (int) ceil($amount))
|
||||
: ($wallet_balance + 0.0001 >= (float) $payable_amount);
|
||||
? ($points_balance >= $payable_minor)
|
||||
: ($wallet_minor >= $payable_minor);
|
||||
|
||||
return [
|
||||
'post_id' => $post_id,
|
||||
@@ -161,36 +159,38 @@ trait PayHelpers {
|
||||
'pay_modo' => $pay_modo,
|
||||
'pay_limit' => $pay_limit,
|
||||
'vip_level' => $vip_level,
|
||||
'amount' => $pay_modo === 'points' ? (int) ceil($amount) : round($amount, 2),
|
||||
'base' => $pay_modo === 'points' ? (int) ceil($base) : round($base, 2),
|
||||
'list_amount' => $pay_modo === 'points' ? (int) ceil($list_amount) : round($list_amount, 2),
|
||||
'amount' => $this->asset_value($amount_minor, $pay_modo),
|
||||
'base' => $this->asset_value($base_minor, $pay_modo),
|
||||
'list_amount' => $this->asset_value($list_minor, $pay_modo),
|
||||
'list_amount_label' => $list_label,
|
||||
'payable' => $payable_amount,
|
||||
'original' => (float) ($priced['original'] ?? 0),
|
||||
'vip1' => (float) ($priced['vip1'] ?? 0),
|
||||
'vip2' => (float) ($priced['vip2'] ?? 0),
|
||||
'original' => $this->asset_value($this->parse_asset_minor($priced['original'] ?? ($pay_modo === 'points' ? 0 : '0'), $pay_modo), $pay_modo),
|
||||
'vip1' => $this->asset_value($this->parse_asset_minor($priced['vip1'] ?? ($pay_modo === 'points' ? 0 : '0'), $pay_modo), $pay_modo),
|
||||
'vip2' => $this->asset_value($this->parse_asset_minor($priced['vip2'] ?? ($pay_modo === 'points' ? 0 : '0'), $pay_modo), $pay_modo),
|
||||
'currency' => $currency,
|
||||
'amount_label' => $amount_label,
|
||||
'base_label' => $base_label,
|
||||
'is_free' => !empty($priced['is_free']) || $amount <= 0,
|
||||
'is_free' => !empty($priced['is_free']) || $amount_minor <= 0,
|
||||
'has_paid_access' => $has_paid,
|
||||
'paid_order' => $paid_order ? [
|
||||
'order_number' => (string) ($paid_order['order_number'] ?? ''),
|
||||
'paid_at' => (string) ($paid_order['paid_at'] ?? $paid_order['created_at'] ?? ''),
|
||||
'amount' => (float) ($paid_order['amount'] ?? 0),
|
||||
'amount' => (string) ($paid_order['currency'] ?? '') === 'points'
|
||||
? (int) ($paid_order['amount'] ?? 0)
|
||||
: (string) ($paid_order['amount'] ?? '0.00'),
|
||||
'currency' => (string) ($paid_order['currency'] ?? ''),
|
||||
'payment_method' => (string) ($paid_order['payment_method'] ?? ''),
|
||||
] : null,
|
||||
'pending_order' => $pending_order ? [
|
||||
'order_number' => (string) ($pending_order['order_number'] ?? ''),
|
||||
'amount' => (float) ($pending_order['amount'] ?? 0),
|
||||
'amount' => (string) ($pending_order['amount'] ?? '0.00'),
|
||||
'payment_method' => (string) ($pending_order['payment_method'] ?? ''),
|
||||
'created_at' => (string) ($pending_order['created_at'] ?? ''),
|
||||
] : null,
|
||||
'need_login' => $uid <= 0 && !$allow_guest,
|
||||
'allow_guest' => $allow_guest,
|
||||
'need_vip' => $need_vip,
|
||||
'need_purchase' => !$has_paid && !$need_vip && $amount > 0,
|
||||
'need_purchase' => !$has_paid && !$need_vip && $amount_minor > 0,
|
||||
'logged_in' => $uid > 0,
|
||||
'balance' => $balance,
|
||||
'wallet_balance' => $wallet_balance,
|
||||
@@ -200,9 +200,9 @@ trait PayHelpers {
|
||||
'login_url' => wp_login_url(get_permalink($post)),
|
||||
'vip_url' => $vip_page,
|
||||
'credits_url' => $vip_page,
|
||||
'download_limit_over_price' => $pay_modo === 'points' ? (int) ceil($over_price) : round($over_price, 2),
|
||||
'download_limit_over_price' => $this->asset_value($over_minor, $pay_modo),
|
||||
'download_limit_over_label' => $over_label,
|
||||
'allow_limit_over' => $over_price > 0,
|
||||
'allow_limit_over' => $over_minor > 0,
|
||||
'quota_over' => $quota_over,
|
||||
'free_limit' => $free_limit,
|
||||
'free_used' => $free_used,
|
||||
@@ -211,18 +211,18 @@ trait PayHelpers {
|
||||
'coupon_code' => $coupon_code,
|
||||
'coupon' => $coupon_payload,
|
||||
'coupon_error' => $coupon_error,
|
||||
'rebate_discount' => round($rebate_discount, 2),
|
||||
'rebate_discount_label' => $rebate_discount > 0 ? ('-' . $this->format_amount_label($rebate_discount, $pay_modo, $currency)) : '',
|
||||
'coupon_discount' => round($coupon_discount, 2),
|
||||
'coupon_discount_label' => $coupon_discount > 0 ? ('-' . $this->format_amount_label($coupon_discount, $pay_modo, $currency)) : '',
|
||||
'rebate_discount' => $this->asset_value($rebate_discount_minor, $pay_modo),
|
||||
'rebate_discount_label' => $rebate_discount_minor > 0 ? ('-' . $this->format_amount_label($rebate_discount_minor, $pay_modo, $currency)) : '',
|
||||
'coupon_discount' => $this->asset_value($coupon_discount_minor, $pay_modo),
|
||||
'coupon_discount_label' => $coupon_discount_minor > 0 ? ('-' . $this->format_amount_label($coupon_discount_minor, $pay_modo, $currency)) : '',
|
||||
'referrer_active' => $referrer_active,
|
||||
'discounts' => [
|
||||
'list_amount' => round($list_amount, 2),
|
||||
'rebate_discount' => round($rebate_discount, 2),
|
||||
'coupon_discount' => round($coupon_discount, 2),
|
||||
'total_discount' => round(max(0, $list_amount - $payable), 2),
|
||||
'payable' => round($payable, 2),
|
||||
'rebate_price' => round($rebate_price, 2),
|
||||
'list_amount' => $this->asset_value($list_minor, $pay_modo),
|
||||
'rebate_discount' => $this->asset_value($rebate_discount_minor, $pay_modo),
|
||||
'coupon_discount' => $this->asset_value($coupon_discount_minor, $pay_modo),
|
||||
'total_discount' => $this->asset_value(max(0, $list_minor - $payable_minor), $pay_modo),
|
||||
'payable' => $this->asset_value($payable_minor, $pay_modo),
|
||||
'rebate_price' => $this->asset_value($rebate_price_minor, $pay_modo),
|
||||
],
|
||||
// legacy: points_deduction runtime hard-off; card_pass not a content payment method.
|
||||
'points_deduction_allowed' => function_exists('aksrt_is_allow_points_deduction')
|
||||
@@ -269,18 +269,18 @@ trait PayHelpers {
|
||||
|
||||
$priced = function_exists('aksrt_get_user_pay_price')
|
||||
? aksrt_get_user_pay_price($post, $user_id)
|
||||
: ['amount' => 0.0, 'modo' => '0', 'pay_limit' => 0, 'vip_level' => 0, 'is_free' => true, 'base' => 0.0];
|
||||
: ['amount' => '0.00', 'modo' => '0', 'pay_limit' => 0, 'vip_level' => 0, 'is_free' => true, 'base' => '0.00'];
|
||||
|
||||
// Free daily quota overbuy: override amount, ignore VIP discount (legacy).
|
||||
if ($price_type === 'download_limit_over') {
|
||||
$over = max(0.0, (float) ($meta['download_limit_over_price'] ?? 0));
|
||||
if ($over <= 0) {
|
||||
$over_minor = $this->parse_asset_minor($meta['download_limit_over_price'] ?? ($pay_modo_meta === 'points' ? 0 : '0'), $pay_modo_meta);
|
||||
if ($over_minor <= 0) {
|
||||
return new \WP_Error('aksrt_no_overbuy', __('未开放超额下载购买。', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$priced['amount'] = $over;
|
||||
$priced['base'] = $over;
|
||||
$priced['amount'] = $this->asset_value($over_minor, $pay_modo_meta);
|
||||
$priced['base'] = $this->asset_value($over_minor, $pay_modo_meta);
|
||||
$priced['is_free'] = false;
|
||||
$priced['vip_amount'] = -1.0;
|
||||
$priced['vip_amount'] = null;
|
||||
}
|
||||
|
||||
$pay_limit = (int) ($priced['pay_limit'] ?? 0);
|
||||
@@ -365,10 +365,10 @@ trait PayHelpers {
|
||||
|
||||
|
||||
/**
|
||||
* @return array{id:int,ratio:float}
|
||||
* @return array{id:int,ratio:int}
|
||||
*/
|
||||
private function resolve_referrer_context(int $user_id): array {
|
||||
$empty = ['id' => 0, 'ratio' => 0.0];
|
||||
$empty = ['id' => 0, 'ratio' => 0];
|
||||
if (!class_exists('\AKSRT\Settings\Settings') || !method_exists('\AKSRT\Settings\Settings', 'get_payment_affiliate_settings')) {
|
||||
return $empty;
|
||||
}
|
||||
@@ -383,7 +383,7 @@ trait PayHelpers {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$ratio = (float) \AKSRT\Settings\Settings::affiliate_ratio_for_user($referrer_id);
|
||||
$ratio = (int) \AKSRT\Settings\Settings::affiliate_ratio_for_user($referrer_id);
|
||||
if ($ratio <= 0) {
|
||||
return $empty;
|
||||
}
|
||||
@@ -400,21 +400,21 @@ trait PayHelpers {
|
||||
* @return array|\WP_Error
|
||||
*/
|
||||
private function apply_cash_discounts(array $priced, \WP_Post $post, array $meta, int $user_id, string $coupon_code = '', bool $soft_coupon = false) {
|
||||
$list_amount = round(max(0.0, (float) ($priced['amount'] ?? 0)), 2);
|
||||
$pay = $list_amount;
|
||||
$rebate_discount = 0.0;
|
||||
$coupon_discount = 0.0;
|
||||
$list_minor = $this->parse_asset_minor($priced['amount'] ?? '0', '0');
|
||||
$pay_minor = $list_minor;
|
||||
$rebate_discount_minor = 0;
|
||||
$coupon_discount_minor = 0;
|
||||
$coupon_meta = [];
|
||||
|
||||
$ref = $this->resolve_referrer_context($user_id);
|
||||
$referrer_id = (int) ($ref['id'] ?? 0);
|
||||
$ratio = (float) ($ref['ratio'] ?? 0);
|
||||
$ratio = max(0, min(100, (int) ($ref['ratio'] ?? 0)));
|
||||
|
||||
// 1) Buyer-facing 推广折扣 (fixed).
|
||||
$fixed = max(0.0, (float) ($meta['pay_rebate_discount'] ?? 0));
|
||||
if ($referrer_id > 0 && $ratio > 0 && $fixed > 0 && $pay > 0) {
|
||||
$rebate_discount = round(min($pay, $fixed), 2);
|
||||
$pay = round(max(0.0, $pay - $rebate_discount), 2);
|
||||
$fixed_minor = $this->parse_asset_minor($meta['pay_rebate_discount'] ?? '0', '0');
|
||||
if ($referrer_id > 0 && $ratio > 0 && $fixed_minor > 0 && $pay_minor > 0) {
|
||||
$rebate_discount_minor = min($pay_minor, $fixed_minor);
|
||||
$pay_minor -= $rebate_discount_minor;
|
||||
}
|
||||
|
||||
// 2) Coupon after rebate discount.
|
||||
@@ -431,11 +431,15 @@ trait PayHelpers {
|
||||
return $err;
|
||||
}
|
||||
} else {
|
||||
$result = \AKSRT\Payment\Coupon::get_instance()->validate($coupon_code, $pay, [
|
||||
$result = \AKSRT\Payment\Coupon::get_instance()->validate(
|
||||
$coupon_code,
|
||||
\AKSRT\Ledger\AssetAmount::decimal_from_minor($pay_minor),
|
||||
[
|
||||
'user_id' => max(0, $user_id),
|
||||
'product_ids' => [(int) $post->ID],
|
||||
'context' => 'content',
|
||||
]);
|
||||
]
|
||||
);
|
||||
if (empty($result['valid'])) {
|
||||
$err = new \WP_Error(
|
||||
'aksrt_coupon_error',
|
||||
@@ -446,47 +450,50 @@ trait PayHelpers {
|
||||
return $err;
|
||||
}
|
||||
} else {
|
||||
$coupon_discount = round(max(0.0, (float) ($result['discount'] ?? 0)), 2);
|
||||
if ($coupon_discount > $pay) {
|
||||
$coupon_discount = $pay;
|
||||
}
|
||||
$pay = round(max(0.0, $pay - $coupon_discount), 2);
|
||||
$coupon_discount_minor = min($pay_minor, max(0, (int) ($result['discount_minor'] ?? 0)));
|
||||
$pay_minor -= $coupon_discount_minor;
|
||||
$c = is_array($result['coupon'] ?? null) ? $result['coupon'] : [];
|
||||
$coupon_meta = [
|
||||
'id' => (int) ($c['id'] ?? 0),
|
||||
'code' => (string) ($c['code'] ?? $coupon_code),
|
||||
'type' => (string) ($c['type'] ?? ''),
|
||||
'value' => (float) ($c['value'] ?? 0),
|
||||
'discount' => $coupon_discount,
|
||||
'value' => $c['value'] ?? 0,
|
||||
'discount' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($coupon_discount_minor),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$payable = round(max(0.0, $pay), 2);
|
||||
$rebate_price = 0.0;
|
||||
if ($referrer_id > 0 && $ratio > 0 && $payable > 0) {
|
||||
$rebate_price = round($payable * $ratio / 100, 2);
|
||||
$payable_minor = max(0, $pay_minor);
|
||||
$rebate_price_minor = 0;
|
||||
if ($referrer_id > 0 && $ratio > 0 && $payable_minor > 0) {
|
||||
$rebate_price_minor = \AKSRT\Ledger\AssetAmount::percentage($payable_minor, $ratio);
|
||||
}
|
||||
|
||||
return [
|
||||
'list_amount' => $list_amount,
|
||||
'rebate_discount' => $rebate_discount,
|
||||
'coupon_discount' => $coupon_discount,
|
||||
'list_amount' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($list_minor),
|
||||
'list_minor' => $list_minor,
|
||||
'rebate_discount' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($rebate_discount_minor),
|
||||
'rebate_discount_minor' => $rebate_discount_minor,
|
||||
'coupon_discount' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($coupon_discount_minor),
|
||||
'coupon_discount_minor' => $coupon_discount_minor,
|
||||
'coupon' => $coupon_meta,
|
||||
'payable' => $payable,
|
||||
'payable' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($payable_minor),
|
||||
'payable_minor' => $payable_minor,
|
||||
'referrer_id' => $referrer_id,
|
||||
'rebate_price' => $rebate_price,
|
||||
'rebate_price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($rebate_price_minor),
|
||||
'rebate_price_minor' => $rebate_price_minor,
|
||||
'ratio' => $ratio,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function format_amount_label(float $amount, string $pay_modo, string $currency): string {
|
||||
if ($amount <= 0) {
|
||||
private function format_amount_label(int $minor, string $pay_modo, string $currency): string {
|
||||
if ($minor <= 0) {
|
||||
return (string) __('免费', 'aksrt');
|
||||
}
|
||||
$amount = $this->asset_value($minor, $pay_modo);
|
||||
$n = function_exists('aksrt_format_price') ? aksrt_format_price($amount) : (string) $amount;
|
||||
if ($pay_modo === 'points') {
|
||||
return $n . ' ' . $currency;
|
||||
@@ -494,4 +501,37 @@ trait PayHelpers {
|
||||
return $currency . $n;
|
||||
}
|
||||
|
||||
private function parse_asset_minor($value, string $pay_modo): int {
|
||||
if ($pay_modo === 'points') {
|
||||
if (is_int($value)) {
|
||||
return max(0, $value);
|
||||
}
|
||||
if (!is_string($value) || !preg_match('/^(0|[1-9]\d*)$/', trim($value))) {
|
||||
return 0;
|
||||
}
|
||||
$value = trim($value);
|
||||
if (strlen($value) > strlen((string) PHP_INT_MAX)
|
||||
|| (strlen($value) === strlen((string) PHP_INT_MAX) && strcmp($value, (string) PHP_INT_MAX) > 0)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
return \AKSRT\Ledger\AssetAmount::points((int) $value)->minor();
|
||||
}
|
||||
|
||||
if (!is_string($value)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance_minor($value);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function asset_value(int $minor, string $pay_modo) {
|
||||
return $pay_modo === 'points'
|
||||
? $minor
|
||||
: \AKSRT\Ledger\AssetAmount::decimal_from_minor($minor);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+208
-108
@@ -17,19 +17,19 @@ trait PayOrders {
|
||||
* @param string $order_number
|
||||
* @param array $paid_order
|
||||
*/
|
||||
public function on_order_paid(string $order_number, array $paid_order): void {
|
||||
public function fulfill_paid_order(string $order_number, array $paid_order) {
|
||||
$product_type = sanitize_key((string) ($paid_order['product_type'] ?? ''));
|
||||
if ($product_type !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
$post_id = (int) ($paid_order['product_id'] ?? 0);
|
||||
if ($post_id <= 0) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// Only count normal posts for content purchases.
|
||||
$post = get_post($post_id);
|
||||
if (!($post instanceof \WP_Post) || $post->post_type !== 'post') {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (function_exists('aksrt_bump_content_sales_volume')) {
|
||||
aksrt_bump_content_sales_volume($post_id, 1);
|
||||
@@ -53,21 +53,31 @@ trait PayOrders {
|
||||
}
|
||||
|
||||
// Consume coupon + write affiliate commission (cash content only; idempotent).
|
||||
$this->finalize_content_order_side_effects($order_number, $paid_order);
|
||||
$side_effects = $this->finalize_content_order_side_effects($order_number, $paid_order);
|
||||
if (is_wp_error($side_effects)) {
|
||||
return $side_effects;
|
||||
}
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$completed = \AKSRT\Payment\OrderRepository::get_instance()->update_status(
|
||||
$order_number,
|
||||
\AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
['note' => __('内容权益已发放。', 'aksrt')]
|
||||
);
|
||||
if ($completed && class_exists('\AKSRT\Payment\CreatorRevenue')) {
|
||||
if (!$completed) {
|
||||
return new \WP_Error('aksrt_content_fulfillment_failed', __('内容权益发放失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
if (class_exists('\AKSRT\Payment\CreatorRevenue')) {
|
||||
$completed_order = array_merge($paid_order, [
|
||||
'order_number' => $order_number,
|
||||
'status' => \AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
]);
|
||||
\AKSRT\Payment\CreatorRevenue::get_instance()->create_from_paid_order($completed_order);
|
||||
}
|
||||
$revenue_id = \AKSRT\Payment\CreatorRevenue::get_instance()->create_from_paid_order($completed_order);
|
||||
if ($revenue_id < 0) {
|
||||
return new \WP_Error('aksrt_creator_revenue_conflict', __('同一订单的收益载荷不一致。', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -75,25 +85,29 @@ trait PayOrders {
|
||||
/**
|
||||
* On paid: burn coupon (if any) and create affiliate rebate log.
|
||||
*/
|
||||
private function finalize_content_order_side_effects(string $order_number, array $order): void {
|
||||
private function finalize_content_order_side_effects(string $order_number, array $order) {
|
||||
$meta = $this->decode_order_note_meta($order);
|
||||
if (empty($meta['content_pay'])) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// Points: no coupon burn / no cash commission.
|
||||
if (($meta['pay_modo'] ?? '') === 'points') {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
$buyer_id = (int) ($order['user_id'] ?? 0);
|
||||
|
||||
// Coupon burn (shared helper; idempotent via coupon_usage.order_number).
|
||||
if (function_exists('aksrt_burn_order_coupon')) {
|
||||
aksrt_burn_order_coupon($order_number, $meta, $buyer_id);
|
||||
$coupon = is_array($meta['coupon'] ?? null) ? $meta['coupon'] : [];
|
||||
$coupon_code = (string) ($coupon['code'] ?? $meta['coupon_code'] ?? '');
|
||||
if ($coupon_code !== '' && !aksrt_burn_order_coupon($order_number, $meta, $buyer_id)) {
|
||||
return new \WP_Error('aksrt_coupon_consume_failed', __('优惠券核销失败。', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
}
|
||||
|
||||
// Affiliate commission is recorded exactly once by CommissionLedger on
|
||||
// the canonical aksrt_order_paid event. No order-side legacy writes.
|
||||
// Affiliate commission is written by the order transition transaction.
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +135,7 @@ trait PayOrders {
|
||||
*
|
||||
* @return array|\WP_Error
|
||||
*/
|
||||
public function create_and_pay_points(int $post_id, int $user_id, string $price_type = '') {
|
||||
public function create_and_pay_points(int $post_id, int $user_id, string $price_type = '', string $operation_key = '') {
|
||||
$assert = $this->assert_purchasable($post_id, $user_id, $price_type);
|
||||
if (is_wp_error($assert)) {
|
||||
return $assert;
|
||||
@@ -132,7 +146,7 @@ trait PayOrders {
|
||||
return new \WP_Error('aksrt_wrong_modo', __('该内容不支持积分支付。', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$amount = (int) ceil((float) ($priced['amount'] ?? 0));
|
||||
$amount = $this->parse_asset_minor($priced['amount'] ?? 0, 'points');
|
||||
if ($amount <= 0) {
|
||||
return [
|
||||
'success' => true,
|
||||
@@ -144,13 +158,14 @@ trait PayOrders {
|
||||
];
|
||||
}
|
||||
|
||||
if (!class_exists('\AKSRT\Payment\Order') || !class_exists('\AKSRT\Credits\Credits')) {
|
||||
if (!class_exists('\AKSRT\Payment\Order')) {
|
||||
return new \WP_Error('aksrt_pay_unavailable', __('支付系统不可用。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
$balance = \AKSRT\Credits\Credits::balance($user_id);
|
||||
$ledger = \AKSRT\Ledger\LedgerService::get_instance();
|
||||
$balance = $ledger->balance($user_id, \AKSRT\Ledger\AssetType::POINTS);
|
||||
if ($balance < $amount) {
|
||||
return new \WP_Error('aksrt_credits_insufficient', __('积分不足,暂时无法支付。', 'aksrt'), [
|
||||
return new \WP_Error('aksrt_points_insufficient', __('积分不足,暂时无法支付。', 'aksrt'), [
|
||||
'status' => 402,
|
||||
'balance' => $balance,
|
||||
'cost' => $amount,
|
||||
@@ -163,69 +178,93 @@ trait PayOrders {
|
||||
'post_id' => $post_id,
|
||||
'price_type' => $price_type,
|
||||
'vip_level' => (int) ($priced['vip_level'] ?? 0),
|
||||
'base' => (float) ($priced['base'] ?? $amount),
|
||||
'base' => $this->parse_asset_minor($priced['base'] ?? $amount, 'points'),
|
||||
'amount' => $amount,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
||||
'product_id' => $post_id,
|
||||
'amount' => (float) $amount,
|
||||
'currency' => 'points',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => 'points',
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
|
||||
]);
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$existing = $operation_key !== '' ? $repo->get_by_request_key($operation_key) : null;
|
||||
$is_new_order = !is_array($existing);
|
||||
if (is_array($existing)) {
|
||||
$existing_meta = $this->decode_order_note_meta($existing);
|
||||
if ((int) ($existing['user_id'] ?? 0) !== $user_id
|
||||
|| sanitize_key((string) ($existing['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE
|
||||
|| (int) ($existing['product_id'] ?? 0) !== $post_id
|
||||
|| (string) ($existing['currency'] ?? '') !== 'points'
|
||||
|| (int) ($existing['amount'] ?? 0) !== $amount
|
||||
|| sanitize_key((string) ($existing['payment_method'] ?? '')) !== 'points'
|
||||
|| (string) ($existing_meta['price_type'] ?? '') !== $price_type
|
||||
) {
|
||||
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他购买请求。', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$order_number = (string) $existing['order_number'];
|
||||
} else {
|
||||
$order_number = $repo->allocate_order_number();
|
||||
}
|
||||
|
||||
if ($order_number === '') {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$guard = static function () use ($is_new_order, $order_number, $user_id, $post_id, $amount) {
|
||||
if ($is_new_order) {
|
||||
return true;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('order');
|
||||
$current = $wpdb->get_row($wpdb->prepare("SELECT user_id, product_id, amount, currency, status FROM {$table} WHERE order_number = %s FOR UPDATE", $order_number), ARRAY_A);
|
||||
if (!is_array($current)
|
||||
|| (int) ($current['user_id'] ?? 0) !== $user_id
|
||||
|| (int) ($current['product_id'] ?? 0) !== $post_id
|
||||
|| (int) ($current['amount'] ?? 0) !== $amount
|
||||
|| (string) ($current['currency'] ?? '') !== 'points'
|
||||
|| !in_array((string) ($current['status'] ?? ''), [\AKSRT\Payment\Order::STATUS_CREATED, \AKSRT\Payment\Order::STATUS_PENDING], true)
|
||||
) {
|
||||
return new \WP_Error('aksrt_order_confirm_failed', __('订单状态或金额已变化。', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
$lock = class_exists('\AKSRT\Payment\PaymentAttemptLock')
|
||||
? \AKSRT\Payment\PaymentAttemptLock::acquire($order_number)
|
||||
: false;
|
||||
if ($lock === false) {
|
||||
return new \WP_Error('aksrt_pay_locked', __('该订单正在发起支付,请稍后重试。', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
|
||||
try {
|
||||
$deducted = \AKSRT\Credits\Credits::deduct(
|
||||
$user_id,
|
||||
$amount,
|
||||
'content_points_pay',
|
||||
['type' => 'post', 'id' => $post_id, 'order' => $order_number],
|
||||
sprintf(__('积分购买《%s》', 'aksrt'), get_the_title($post_id))
|
||||
);
|
||||
|
||||
if (is_wp_error($deducted) || $deducted === false) {
|
||||
\AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_CANCELLED, [
|
||||
'note' => __('积分扣除失败,自动取消', 'aksrt'),
|
||||
]);
|
||||
$msg = is_wp_error($deducted) ? $deducted->get_error_message() : __('积分扣除失败。', 'aksrt');
|
||||
return new \WP_Error('aksrt_credits_deduct_failed', $msg ?: __('积分扣除失败。', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||
$deducted = $ledger->debit(
|
||||
$user_id,
|
||||
\AKSRT\Ledger\AssetType::POINTS,
|
||||
$amount,
|
||||
$operation_key,
|
||||
'content_points_pay',
|
||||
['type' => 'order', 'key' => $order_number],
|
||||
sprintf(__('积分购买《%s》', 'aksrt'), get_the_title($post_id)),
|
||||
$user_id,
|
||||
['post_id' => $post_id],
|
||||
static function () use ($is_new_order, $repo, $order_number, $operation_key, $user_id, $post_id, $amount, $note) {
|
||||
if ($is_new_order) {
|
||||
$created = $repo->create([
|
||||
'order_number' => $order_number,
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
||||
'product_id' => $post_id,
|
||||
'amount' => $amount,
|
||||
'currency' => 'points',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => 'points',
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
|
||||
'request_key' => $operation_key,
|
||||
]);
|
||||
if ($created !== $order_number) {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
}
|
||||
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||
'payment_method' => 'points',
|
||||
'payment_transaction_id' => 'points_' . $order_number,
|
||||
'note' => sprintf(__('积分支付 %d 积分。', 'aksrt'), $amount),
|
||||
'require_transition' => true,
|
||||
]);
|
||||
|
||||
if (!$paid) {
|
||||
\AKSRT\Credits\Credits::add(
|
||||
$user_id,
|
||||
$amount,
|
||||
'content_points_pay_refund',
|
||||
['type' => 'post', 'id' => $post_id, 'order' => $order_number],
|
||||
sprintf(__('购买失败退款:%s', 'aksrt'), get_the_title($post_id))
|
||||
);
|
||||
return new \WP_Error('aksrt_order_confirm_failed', __('订单确认失败,已尝试退回积分。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
} finally {
|
||||
\AKSRT\Payment\PaymentAttemptLock::release($order_number, $lock);
|
||||
]);
|
||||
return $paid ? true : new \WP_Error('aksrt_order_confirm_failed', __('订单确认失败。', 'aksrt'), ['status' => 500]);
|
||||
},
|
||||
$guard
|
||||
);
|
||||
if (is_wp_error($deducted)) {
|
||||
return $deducted;
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -234,7 +273,9 @@ trait PayOrders {
|
||||
'order_number' => $order_number,
|
||||
'amount' => $amount,
|
||||
'pay_modo' => 'points',
|
||||
'balance' => \AKSRT\Credits\Credits::balance($user_id),
|
||||
'balance' => $ledger->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
'transaction_id' => $deducted->transaction_id,
|
||||
'replayed' => $deducted->replayed,
|
||||
'message' => __('支付成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
],
|
||||
@@ -248,7 +289,7 @@ trait PayOrders {
|
||||
*
|
||||
* @return array|\WP_Error
|
||||
*/
|
||||
public function create_cash_order(int $post_id, int $user_id, string $payment_method = 'balance', string $price_type = '', string $coupon_code = '') {
|
||||
public function create_cash_order(int $post_id, int $user_id, string $payment_method = 'balance', string $price_type = '', string $coupon_code = '', string $operation_key = '') {
|
||||
$assert = $this->assert_purchasable($post_id, $user_id, $price_type);
|
||||
if (is_wp_error($assert)) {
|
||||
return $assert;
|
||||
@@ -279,15 +320,20 @@ trait PayOrders {
|
||||
return $discounted;
|
||||
}
|
||||
|
||||
$amount = round((float) ($discounted['payable'] ?? 0), 2);
|
||||
$list_amount = round((float) ($discounted['list_amount'] ?? $amount), 2);
|
||||
$rebate_discount = round((float) ($discounted['rebate_discount'] ?? 0), 2);
|
||||
$coupon_discount = round((float) ($discounted['coupon_discount'] ?? 0), 2);
|
||||
$amount_minor = max(0, (int) ($discounted['payable_minor'] ?? 0));
|
||||
$list_minor = max(0, (int) ($discounted['list_minor'] ?? $amount_minor));
|
||||
$rebate_discount_minor = max(0, (int) ($discounted['rebate_discount_minor'] ?? 0));
|
||||
$coupon_discount_minor = max(0, (int) ($discounted['coupon_discount_minor'] ?? 0));
|
||||
$amount = \AKSRT\Ledger\AssetAmount::decimal_from_minor($amount_minor);
|
||||
$list_amount = \AKSRT\Ledger\AssetAmount::decimal_from_minor($list_minor);
|
||||
$rebate_discount = \AKSRT\Ledger\AssetAmount::decimal_from_minor($rebate_discount_minor);
|
||||
$coupon_discount = \AKSRT\Ledger\AssetAmount::decimal_from_minor($coupon_discount_minor);
|
||||
$coupon_meta = is_array($discounted['coupon'] ?? null) ? $discounted['coupon'] : [];
|
||||
$referrer_id = (int) ($discounted['referrer_id'] ?? 0);
|
||||
$rebate_price = round((float) ($discounted['rebate_price'] ?? 0), 2);
|
||||
$rebate_price_minor = max(0, (int) ($discounted['rebate_price_minor'] ?? 0));
|
||||
$rebate_price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($rebate_price_minor);
|
||||
|
||||
if ($amount <= 0) {
|
||||
if ($amount_minor <= 0) {
|
||||
// Free after discount (or free content): create 0-paid order for access + burn coupon.
|
||||
if (!class_exists('\AKSRT\Payment\Order') || !class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
return new \WP_Error('aksrt_pay_unavailable', __('支付系统不可用。', 'aksrt'), ['status' => 500]);
|
||||
@@ -298,50 +344,71 @@ trait PayOrders {
|
||||
'post_id' => $post_id,
|
||||
'price_type' => $price_type,
|
||||
'vip_level' => (int) ($priced['vip_level'] ?? 0),
|
||||
'base' => (float) ($priced['base'] ?? $list_amount),
|
||||
'base' => $this->asset_value($this->parse_asset_minor($priced['base'] ?? $list_amount, '0'), '0'),
|
||||
'list_amount' => $list_amount,
|
||||
'rebate_discount' => $rebate_discount,
|
||||
'coupon_discount' => $coupon_discount,
|
||||
'discount' => $coupon_discount,
|
||||
'coupon' => $coupon_meta,
|
||||
'coupon_code' => (string) ($coupon_meta['code'] ?? $coupon_code),
|
||||
'amount' => 0,
|
||||
'payable' => 0,
|
||||
'amount' => '0.00',
|
||||
'payable' => '0.00',
|
||||
'referrer_id' => $referrer_id,
|
||||
'rebate_price' => 0,
|
||||
'rebate_price' => '0.00',
|
||||
'ip_address' => function_exists('aksrt_pay_client_ip') ? aksrt_pay_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''),
|
||||
'guest' => $user_id <= 0 ? 1 : 0,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$existing = $operation_key !== '' ? $repo->get_by_request_key($operation_key) : null;
|
||||
$replayed = is_array($existing);
|
||||
if (is_array($existing)) {
|
||||
if ((int) ($existing['user_id'] ?? 0) !== max(0, $user_id)
|
||||
|| sanitize_key((string) ($existing['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE
|
||||
|| (int) ($existing['product_id'] ?? 0) !== $post_id
|
||||
|| sanitize_key((string) ($existing['payment_method'] ?? '')) !== 'free'
|
||||
|| (string) ($existing['amount'] ?? '') !== '0.00'
|
||||
) {
|
||||
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他内容请求。', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$order_number = (string) $existing['order_number'];
|
||||
} else {
|
||||
$order_number = $repo->create([
|
||||
'user_id' => max(0, $user_id),
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
||||
'product_id' => $post_id,
|
||||
'amount' => 0.0,
|
||||
'amount' => '0.00',
|
||||
'currency' => 'CNY',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => 'free',
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
|
||||
]);
|
||||
'request_key' => $operation_key,
|
||||
]);
|
||||
}
|
||||
if ($order_number === '') {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||
'payment_method' => 'free',
|
||||
'payment_transaction_id' => 'free_' . $order_number,
|
||||
'note' => __('优惠后无需支付。', 'aksrt'),
|
||||
]);
|
||||
if (!$paid) {
|
||||
return new \WP_Error('aksrt_order_confirm_failed', __('订单确认失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$status = is_array($existing) ? (string) ($existing['status'] ?? '') : '';
|
||||
if (!in_array($status, [\AKSRT\Payment\Order::STATUS_PAID, \AKSRT\Payment\Order::STATUS_DELIVERED, \AKSRT\Payment\Order::STATUS_COMPLETED], true)) {
|
||||
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||
'payment_method' => 'free',
|
||||
'payment_transaction_id' => 'free_' . $order_number,
|
||||
'note' => __('优惠后无需支付。', 'aksrt'),
|
||||
]);
|
||||
if (!$paid) {
|
||||
return new \WP_Error('aksrt_order_confirm_failed', __('订单确认失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
}
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order_number' => $order_number,
|
||||
'amount' => 0,
|
||||
'amount' => '0.00',
|
||||
'pay_modo' => '0',
|
||||
'free' => true,
|
||||
'transaction_id' => 'free_' . $order_number,
|
||||
'replayed' => $replayed,
|
||||
'message' => __('无需支付,已可访问。', 'aksrt'),
|
||||
'reload' => true,
|
||||
],
|
||||
@@ -358,21 +425,31 @@ trait PayOrders {
|
||||
if ($user_id <= 0 && in_array($payment_method, ['balance', 'points'], true)) {
|
||||
$payment_method = 'alipay';
|
||||
}
|
||||
$amount_decimal = $amount;
|
||||
|
||||
$coupon_code_norm = (string) ($coupon_meta['code'] ?? $coupon_code);
|
||||
|
||||
// Resume existing pending content order for this post when amount + coupon match (logged-in only).
|
||||
$pending = $user_id > 0 ? $this->find_pending_content_order($user_id, $post_id) : null;
|
||||
// A retry resolves only through its request key; a new operation never reuses another pending order.
|
||||
$pending = $operation_key !== '' ? \AKSRT\Payment\OrderRepository::get_instance()->get_by_request_key($operation_key) : null;
|
||||
$can_resume = false;
|
||||
if ($pending && abs((float) ($pending['amount'] ?? 0) - $amount) < 0.001) {
|
||||
if (is_array($pending)) {
|
||||
$pending_meta = $this->decode_order_note_meta($pending);
|
||||
$pending_coupon = (string) ($pending_meta['coupon_code'] ?? '');
|
||||
if (is_array($pending_meta['coupon'] ?? null)) {
|
||||
$pending_coupon = (string) ($pending_meta['coupon']['code'] ?? $pending_coupon);
|
||||
}
|
||||
if ($pending_coupon === $coupon_code_norm) {
|
||||
$can_resume = true;
|
||||
}
|
||||
$can_resume = (int) ($pending['user_id'] ?? 0) === max(0, $user_id)
|
||||
&& sanitize_key((string) ($pending['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE
|
||||
&& (int) ($pending['product_id'] ?? 0) === $post_id
|
||||
&& (string) ($pending['amount'] ?? '') === $amount_decimal
|
||||
&& strtoupper((string) ($pending['currency'] ?? '')) === 'CNY'
|
||||
&& sanitize_key((string) ($pending['payment_method'] ?? '')) === $payment_method
|
||||
&& (string) ($pending_meta['price_type'] ?? '') === $price_type
|
||||
&& (int) ($pending_meta['post_id'] ?? 0) === $post_id
|
||||
&& $pending_coupon === $coupon_code_norm;
|
||||
if (!$can_resume) {
|
||||
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他内容购买请求。', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
}
|
||||
if ($can_resume) {
|
||||
$order_number = (string) ($pending['order_number'] ?? '');
|
||||
@@ -384,15 +461,15 @@ trait PayOrders {
|
||||
'post_id' => $post_id,
|
||||
'price_type' => $price_type,
|
||||
'vip_level' => (int) ($priced['vip_level'] ?? 0),
|
||||
'base' => (float) ($priced['base'] ?? $list_amount),
|
||||
'base' => $this->asset_value($this->parse_asset_minor($priced['base'] ?? $list_amount, '0'), '0'),
|
||||
'list_amount' => $list_amount,
|
||||
'rebate_discount' => $rebate_discount,
|
||||
'coupon_discount' => $coupon_discount,
|
||||
'discount' => $coupon_discount,
|
||||
'coupon' => $coupon_meta,
|
||||
'coupon_code' => $coupon_code_norm,
|
||||
'amount' => $amount,
|
||||
'payable' => $amount,
|
||||
'amount' => $amount_decimal,
|
||||
'payable' => $amount_decimal,
|
||||
'referrer_id' => $referrer_id,
|
||||
'rebate_price' => $rebate_price,
|
||||
'ip_address' => function_exists('aksrt_pay_client_ip') ? aksrt_pay_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''),
|
||||
@@ -403,12 +480,13 @@ trait PayOrders {
|
||||
'user_id' => max(0, $user_id),
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
||||
'product_id' => $post_id,
|
||||
'amount' => $amount,
|
||||
'amount' => $amount_decimal,
|
||||
'currency' => 'CNY',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => $payment_method,
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
|
||||
'request_key' => $operation_key,
|
||||
]);
|
||||
|
||||
if ($order_number === '') {
|
||||
@@ -425,6 +503,23 @@ trait PayOrders {
|
||||
if ($payment_method === '') {
|
||||
$payment_method = $user_id > 0 ? 'balance' : 'alipay';
|
||||
}
|
||||
if ($can_resume && in_array((string) ($order['status'] ?? ''), [\AKSRT\Payment\Order::STATUS_PAID, \AKSRT\Payment\Order::STATUS_DELIVERED, \AKSRT\Payment\Order::STATUS_COMPLETED], true)) {
|
||||
$ledger_key = \AKSRT\Ledger\OperationKey::deterministic('order-payment', [$order_number]);
|
||||
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $ledger_key, 'page' => 1, 'per_page' => 1]);
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order_number' => $order_number,
|
||||
'amount' => $amount_decimal,
|
||||
'pay_modo' => '0',
|
||||
'transaction_id' => (int) ($history['items'][0]['id'] ?? 0),
|
||||
'replayed' => true,
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('支付成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$order['subject'] = sprintf(
|
||||
/* translators: %s: post title */
|
||||
@@ -433,7 +528,7 @@ trait PayOrders {
|
||||
);
|
||||
$order['payment_method'] = $payment_method;
|
||||
// Ensure gateway uses discounted amount.
|
||||
$order['amount'] = $amount;
|
||||
$order['amount'] = $amount_decimal;
|
||||
|
||||
if (!class_exists('\AKSRT\Payment\Gateway')) {
|
||||
return new \WP_Error('aksrt_pay_unavailable', __('支付网关不可用。', 'aksrt'), ['status' => 500]);
|
||||
@@ -455,7 +550,7 @@ trait PayOrders {
|
||||
return new \WP_Error(
|
||||
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
|
||||
(string) ($payment_result['message'] ?? __('支付失败。', 'aksrt')),
|
||||
['status' => 402, 'order_number' => $order_number]
|
||||
['status' => (int) ($payment_result['status'] ?? 400), 'order_number' => $order_number]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -467,11 +562,13 @@ trait PayOrders {
|
||||
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
|
||||
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
|
||||
'order_number' => $order_number,
|
||||
'amount' => $amount,
|
||||
'amount' => $amount_decimal,
|
||||
'pay_modo' => '0',
|
||||
'message' => $payment_result['message'],
|
||||
'reload' => false,
|
||||
'poll' => true,
|
||||
'transaction_id' => 0,
|
||||
'replayed' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -480,8 +577,11 @@ trait PayOrders {
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order_number' => $order_number,
|
||||
'amount' => $amount,
|
||||
'amount' => $amount_decimal,
|
||||
'pay_modo' => '0',
|
||||
'transaction_id' => (int) ($payment_result['ledger_transaction_id'] ?? 0),
|
||||
'replayed' => !empty($payment_result['replayed']),
|
||||
'balance' => $user_id > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : '0.00',
|
||||
'message' => $payment_result['message'],
|
||||
'reload' => true,
|
||||
],
|
||||
|
||||
+31
-6
@@ -17,11 +17,23 @@ trait PayRest {
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_preview'],
|
||||
'permission_callback' => [$this, 'check_rest_nonce'],
|
||||
'args' => [
|
||||
'id' => ['type' => 'integer', 'required' => true, 'minimum' => 1],
|
||||
'price_type' => ['type' => 'string', 'enum' => ['', 'download_limit_over']],
|
||||
'coupon_code' => ['type' => 'string', 'maxLength' => 64, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_purchase'],
|
||||
'permission_callback' => [$this, 'check_rest_nonce'],
|
||||
'args' => [
|
||||
'id' => ['type' => 'integer', 'required' => true, 'minimum' => 1],
|
||||
'mode' => ['type' => 'string', 'enum' => ['points', 'cash']],
|
||||
'payment_method' => ['type' => 'string', 'maxLength' => 32, 'sanitize_callback' => 'sanitize_key'],
|
||||
'price_type' => ['type' => 'string', 'enum' => ['', 'download_limit_over']],
|
||||
'coupon_code' => ['type' => 'string', 'maxLength' => 64, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -29,6 +41,15 @@ trait PayRest {
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_order_status'],
|
||||
'permission_callback' => [$this, 'check_rest_nonce'],
|
||||
'args' => [
|
||||
'order_number' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'pattern' => '^[A-Za-z0-9_-]+$',
|
||||
'maxLength' => 64,
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -60,7 +81,7 @@ trait PayRest {
|
||||
public function rest_preview(\WP_REST_Request $request) {
|
||||
$post_id = (int) $request['id'];
|
||||
$uid = get_current_user_id();
|
||||
$coupon_code = sanitize_text_field((string) ($request->get_param('coupon_code') ?? $request->get_param('coupon') ?? ''));
|
||||
$coupon_code = sanitize_text_field((string) $request->get_param('coupon_code'));
|
||||
$price_type = sanitize_key((string) ($request->get_param('price_type') ?? ''));
|
||||
if ($price_type !== 'download_limit_over') {
|
||||
$price_type = '';
|
||||
@@ -81,6 +102,10 @@ trait PayRest {
|
||||
public function rest_purchase(\WP_REST_Request $request) {
|
||||
$post_id = (int) $request['id'];
|
||||
$uid = get_current_user_id();
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'content-purchase', $uid);
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $operation_key;
|
||||
}
|
||||
|
||||
$params = $request->get_json_params();
|
||||
if (!is_array($params)) {
|
||||
@@ -91,7 +116,7 @@ trait PayRest {
|
||||
if ($price_type !== 'download_limit_over') {
|
||||
$price_type = '';
|
||||
}
|
||||
$coupon_code = sanitize_text_field((string) ($params['coupon_code'] ?? $params['coupon'] ?? ''));
|
||||
$coupon_code = sanitize_text_field((string) ($params['coupon_code'] ?? ''));
|
||||
|
||||
// Guest allowed only for cash + pay_limit=0 + setting on.
|
||||
if ($uid <= 0) {
|
||||
@@ -121,8 +146,8 @@ trait PayRest {
|
||||
if ($uid <= 0) {
|
||||
return new \WP_Error('aksrt_need_login', __('积分支付请先登录。', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
// Points path: ignore coupon (legacy parity).
|
||||
return $this->rest_wrap_result($this->create_and_pay_points($post_id, $uid, $price_type));
|
||||
// Point purchases do not support coupons.
|
||||
return $this->rest_wrap_result($this->create_and_pay_points($post_id, $uid, $price_type, $operation_key));
|
||||
}
|
||||
|
||||
$payment_method = sanitize_key((string) ($params['payment_method'] ?? 'balance'));
|
||||
@@ -133,7 +158,7 @@ trait PayRest {
|
||||
if ($uid <= 0 && in_array($payment_method, ['balance', 'points'], true)) {
|
||||
$payment_method = 'alipay';
|
||||
}
|
||||
return $this->rest_wrap_result($this->create_cash_order($post_id, $uid, $payment_method, $price_type, $coupon_code));
|
||||
return $this->rest_wrap_result($this->create_cash_order($post_id, $uid, $payment_method, $price_type, $coupon_code, $operation_key));
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +202,7 @@ trait PayRest {
|
||||
'order_number' => $order_number,
|
||||
'status' => $status,
|
||||
'paid' => $paid,
|
||||
'amount' => (float) ($order['amount'] ?? 0),
|
||||
'amount' => (string) ($order['amount'] ?? '0.00'),
|
||||
'currency' => (string) ($order['currency'] ?? ''),
|
||||
'product_id' => (int) ($order['product_id'] ?? 0),
|
||||
'product_type' => (string) ($order['product_type'] ?? ''),
|
||||
|
||||
@@ -29,7 +29,6 @@ class VipService {
|
||||
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
add_action('aksrt_order_paid', [$this, 'on_order_paid'], 20, 2);
|
||||
if (class_exists(VipMembershipExpiry::class)) {
|
||||
VipMembershipExpiry::init();
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ trait VipServicePackages {
|
||||
* Preferred: price|show_price|time|unit|tag|title
|
||||
* Legacy: price|days|title
|
||||
*
|
||||
* @return array<int, array{price:float,show_price:float,time:int,unit:string,tag:string,tag_class:string,title:string,days:int}>
|
||||
* @return array<int, array{price:string,show_price:string,time:int,unit:string,tag:string,tag_class:string,title:string,days:int}>
|
||||
*/
|
||||
public function parse_level_products(string $raw): array {
|
||||
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
|
||||
@@ -28,8 +28,8 @@ trait VipServicePackages {
|
||||
continue;
|
||||
}
|
||||
$parts = array_map('trim', explode('|', $line));
|
||||
$price = isset($parts[0]) ? (float) $parts[0] : 0.0;
|
||||
if ($price <= 0) {
|
||||
$price_minor = $this->parse_vip_money_minor($parts[0] ?? '');
|
||||
if ($price_minor <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ trait VipServicePackages {
|
||||
// Or: price|show_price|time|unit|title (5)
|
||||
if ($n >= 4 && (in_array(strtolower((string) ($parts[3] ?? '')), ['day', 'month', 'd', 'm'], true)
|
||||
|| (isset($parts[3]) && $parts[3] !== '' && !is_numeric($parts[3])))) {
|
||||
$show_price = isset($parts[1]) ? (float) $parts[1] : 0.0;
|
||||
$show_minor = $this->parse_vip_money_minor($parts[1] ?? '0');
|
||||
$time = isset($parts[2]) ? (int) $parts[2] : 0;
|
||||
$unit_raw = strtolower((string) ($parts[3] ?? 'month'));
|
||||
$unit = ($unit_raw === 'day' || $unit_raw === 'd') ? 'day' : 'month';
|
||||
@@ -53,7 +53,7 @@ trait VipServicePackages {
|
||||
}
|
||||
} else {
|
||||
// Legacy: price|days|title
|
||||
$show_price = 0.0;
|
||||
$show_minor = 0;
|
||||
$days_legacy = isset($parts[1]) ? (int) $parts[1] : 0;
|
||||
$time = $days_legacy;
|
||||
$unit = 'day';
|
||||
@@ -74,8 +74,8 @@ trait VipServicePackages {
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'price' => round($price, 2),
|
||||
'show_price' => max(0.0, round($show_price, 2)),
|
||||
'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor),
|
||||
'show_price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor),
|
||||
'time' => $time,
|
||||
'unit' => $unit,
|
||||
'tag' => $tag,
|
||||
@@ -103,14 +103,14 @@ trait VipServicePackages {
|
||||
/**
|
||||
* @deprecated Use parse_level_products — kept as thin adapter for upgrade fixed lines.
|
||||
*
|
||||
* @return array<int, array{price:float,days:int,title:string}>
|
||||
* @return array<int, array{price:string,days:int,title:string}>
|
||||
*/
|
||||
public function parse_packages(string $raw): array {
|
||||
$parsed = $this->parse_level_products($raw);
|
||||
$out = [];
|
||||
foreach ($parsed as $p) {
|
||||
$out[] = [
|
||||
'price' => (float) $p['price'],
|
||||
'price' => (string) $p['price'],
|
||||
'days' => (int) $p['days'],
|
||||
'title' => (string) $p['title'],
|
||||
];
|
||||
@@ -161,12 +161,16 @@ trait VipServicePackages {
|
||||
if ($is_permanent || $expires <= time() || $has_unit) {
|
||||
continue;
|
||||
}
|
||||
$daily = isset($parts[1]) ? (float) $parts[1] : 0.0;
|
||||
if ($daily <= 0) {
|
||||
$daily_minor = $this->parse_vip_money_minor($parts[1] ?? '');
|
||||
if ($daily_minor <= 0) {
|
||||
continue;
|
||||
}
|
||||
$remaining = (int) ceil(max(1, ($expires - time()) / DAY_IN_SECONDS));
|
||||
$price = round(max(0.01, $remaining * $daily), 2);
|
||||
if ($daily_minor > intdiv(PHP_INT_MAX, $remaining)) {
|
||||
continue;
|
||||
}
|
||||
$price_minor = $remaining * $daily_minor;
|
||||
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
|
||||
$title = sanitize_text_field((string) ($parts[2] ?? ''));
|
||||
if ($title === '') {
|
||||
$title = sprintf(__('按剩余 %d 天升级', 'aksrt'), $remaining);
|
||||
@@ -178,7 +182,7 @@ trait VipServicePackages {
|
||||
'action' => 'upgrade',
|
||||
'price' => $price,
|
||||
'base_price' => $price,
|
||||
'show_price' => 0.0,
|
||||
'show_price' => '0.00',
|
||||
'discount_tag' => __('剩余天数', 'aksrt'),
|
||||
'days' => $remaining,
|
||||
'title' => $title,
|
||||
@@ -198,28 +202,30 @@ trait VipServicePackages {
|
||||
if ($is_permanent || $has_jump) {
|
||||
continue;
|
||||
}
|
||||
$price = isset($parts[1]) ? (float) $parts[1] : 0.0;
|
||||
if ($price <= 0) {
|
||||
$price_minor = $this->parse_vip_money_minor($parts[1] ?? '');
|
||||
if ($price_minor <= 0) {
|
||||
continue;
|
||||
}
|
||||
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
|
||||
$title = sanitize_text_field((string) ($parts[2] ?? ''));
|
||||
if ($title === '') {
|
||||
$title = __('升级为永久会员', 'aksrt');
|
||||
}
|
||||
$show = isset($parts[3]) ? (float) $parts[3] : 0.0;
|
||||
$show_minor = $this->parse_vip_money_minor($parts[3] ?? '0');
|
||||
$show = \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor);
|
||||
$items[] = [
|
||||
'package_id' => $this->encode_package_id(2, 903),
|
||||
'level' => 2,
|
||||
'index' => 903,
|
||||
'action' => 'upgrade',
|
||||
'price' => round($price, 2),
|
||||
'base_price' => round($price, 2),
|
||||
'show_price' => max(0.0, $show),
|
||||
'price' => $price,
|
||||
'base_price' => $price,
|
||||
'show_price' => $show,
|
||||
'discount_tag' => __('永久升级', 'aksrt'),
|
||||
'days' => 0,
|
||||
'title' => $title,
|
||||
'price_label' => $this->format_cash_label($price),
|
||||
'show_price_label' => $show > 0 ? $this->format_cash_label($show) : '',
|
||||
'show_price_label' => $show_minor > 0 ? $this->format_cash_label($show) : '',
|
||||
'days_label' => __('永久', 'aksrt'),
|
||||
'cta_label' => __('立即升级', 'aksrt'),
|
||||
'is_upgrade_product' => true,
|
||||
@@ -234,28 +240,30 @@ trait VipServicePackages {
|
||||
if ($has_permanent) {
|
||||
continue;
|
||||
}
|
||||
$price = isset($parts[1]) ? (float) $parts[1] : 0.0;
|
||||
if ($price <= 0) {
|
||||
$price_minor = $this->parse_vip_money_minor($parts[1] ?? '');
|
||||
if ($price_minor <= 0) {
|
||||
continue;
|
||||
}
|
||||
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
|
||||
$title = sanitize_text_field((string) ($parts[2] ?? ''));
|
||||
if ($title === '') {
|
||||
$title = __('永久会员升级', 'aksrt');
|
||||
}
|
||||
$show = isset($parts[3]) ? (float) $parts[3] : 0.0;
|
||||
$show_minor = $this->parse_vip_money_minor($parts[3] ?? '0');
|
||||
$show = \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor);
|
||||
$items[] = [
|
||||
'package_id' => $this->encode_package_id(2, 901),
|
||||
'level' => 2,
|
||||
'index' => 901,
|
||||
'action' => 'upgrade',
|
||||
'price' => round($price, 2),
|
||||
'base_price' => round($price, 2),
|
||||
'show_price' => max(0.0, $show),
|
||||
'price' => $price,
|
||||
'base_price' => $price,
|
||||
'show_price' => $show,
|
||||
'discount_tag' => __('升级专享', 'aksrt'),
|
||||
'days' => 0,
|
||||
'title' => $title,
|
||||
'price_label' => $this->format_cash_label($price),
|
||||
'show_price_label' => $show > 0 ? $this->format_cash_label($show) : '',
|
||||
'show_price_label' => $show_minor > 0 ? $this->format_cash_label($show) : '',
|
||||
'days_label' => __('永久', 'aksrt'),
|
||||
'cta_label' => __('立即升级', 'aksrt'),
|
||||
'is_upgrade_product' => true,
|
||||
@@ -280,13 +288,13 @@ trait VipServicePackages {
|
||||
'level' => 2,
|
||||
'index' => 901,
|
||||
'action' => 'upgrade',
|
||||
'price' => (float) $pkg['price'],
|
||||
'base_price' => (float) $pkg['price'],
|
||||
'show_price' => 0.0,
|
||||
'price' => (string) $pkg['price'],
|
||||
'base_price' => (string) $pkg['price'],
|
||||
'show_price' => '0.00',
|
||||
'discount_tag' => __('升级专享', 'aksrt'),
|
||||
'days' => (int) $pkg['days'],
|
||||
'title' => (string) $pkg['title'],
|
||||
'price_label' => $this->format_cash_label((float) $pkg['price']),
|
||||
'price_label' => $this->format_cash_label((string) $pkg['price']),
|
||||
'show_price_label' => '',
|
||||
'days_label' => ((int) $pkg['days'] <= 0)
|
||||
? __('永久', 'aksrt')
|
||||
@@ -399,10 +407,10 @@ trait VipServicePackages {
|
||||
*
|
||||
* @param array<string,mixed> $renew
|
||||
* @param array<string,mixed> $upgrade
|
||||
* @return array{price:float,show_price:float,tag:string}|null
|
||||
* @return array{price:string,show_price:string,tag:string}|null
|
||||
*/
|
||||
private function apply_action_price(
|
||||
float $base_price,
|
||||
string $base_price,
|
||||
string $action,
|
||||
array $renew,
|
||||
array $upgrade,
|
||||
@@ -412,7 +420,8 @@ trait VipServicePackages {
|
||||
bool $is_permanent,
|
||||
int $days
|
||||
): ?array {
|
||||
if ($base_price <= 0) {
|
||||
$base_minor = $this->parse_vip_money_minor($base_price);
|
||||
if ($base_minor <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -422,57 +431,59 @@ trait VipServicePackages {
|
||||
|
||||
if ($action !== 'renew') {
|
||||
return [
|
||||
'price' => round($base_price, 2),
|
||||
'show_price' => 0.0,
|
||||
'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($base_minor),
|
||||
'show_price' => '0.00',
|
||||
'tag' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$type = sanitize_key((string) ($renew['price_type'] ?? 'discount'));
|
||||
$price = $base_price;
|
||||
$show = $base_price;
|
||||
$price_minor = $base_minor;
|
||||
$show_minor = $base_minor;
|
||||
$tag = '';
|
||||
|
||||
if ($type === 'discount') {
|
||||
$discount = (float) ($renew['discount'] ?? 8);
|
||||
$discount = (int) ($renew['discount'] ?? 8);
|
||||
if ($discount <= 0) {
|
||||
$discount = 8;
|
||||
}
|
||||
if ($discount <= 10) {
|
||||
$price = round($base_price * ($discount / 10), 2);
|
||||
$percent = $discount * 10;
|
||||
$price_minor = \AKSRT\Ledger\AssetAmount::percentage($base_minor, $percent);
|
||||
$tag = sprintf(
|
||||
/* translators: %s: discount like 8 */
|
||||
__('%s折续费', 'aksrt'),
|
||||
rtrim(rtrim(number_format($discount, 1, '.', ''), '0'), '.')
|
||||
(string) $discount
|
||||
);
|
||||
} else {
|
||||
$price = round($base_price * (min(100, $discount) / 100), 2);
|
||||
$percent = min(100, $discount);
|
||||
$price_minor = \AKSRT\Ledger\AssetAmount::percentage($base_minor, $percent);
|
||||
$tag = sprintf(__('%s%% 续费价', 'aksrt'), (string) (int) $discount);
|
||||
}
|
||||
} elseif ($type === 'reduce') {
|
||||
$reduce = max(0, (float) ($renew['reduce'] ?? 20));
|
||||
$price = round(max(0.01, $base_price - $reduce), 2);
|
||||
$reduce_minor = $this->parse_vip_money_minor($renew['reduce'] ?? '20');
|
||||
$price_minor = max(1, $base_minor - $reduce_minor);
|
||||
$tag = sprintf(
|
||||
/* translators: %s: amount */
|
||||
__('续费立减%s', 'aksrt'),
|
||||
$this->format_cash_label($reduce)
|
||||
$this->format_cash_label(\AKSRT\Ledger\AssetAmount::decimal_from_minor($reduce_minor))
|
||||
);
|
||||
} else {
|
||||
$show = 0.0;
|
||||
$show_minor = 0;
|
||||
$tag = __('续费同价', 'aksrt');
|
||||
}
|
||||
|
||||
if ($price < 0.01) {
|
||||
$price = 0.01;
|
||||
if ($price_minor < 1) {
|
||||
$price_minor = 1;
|
||||
}
|
||||
if ($show > 0 && $price >= $show) {
|
||||
$show = 0.0;
|
||||
if ($show_minor > 0 && $price_minor >= $show_minor) {
|
||||
$show_minor = 0;
|
||||
$tag = __('续费', 'aksrt');
|
||||
}
|
||||
|
||||
return [
|
||||
'price' => $price,
|
||||
'show_price' => $show > $price ? $show : 0.0,
|
||||
'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor),
|
||||
'show_price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor > $price_minor ? $show_minor : 0),
|
||||
'tag' => $tag,
|
||||
];
|
||||
}
|
||||
@@ -532,7 +543,7 @@ trait VipServicePackages {
|
||||
|
||||
|
||||
|
||||
private function format_cash_label(float $amount): string {
|
||||
private function format_cash_label(string $amount): string {
|
||||
$symbol = '¥';
|
||||
if (method_exists($this, 'get_paid_display_context')) {
|
||||
$paid = $this->get_paid_display_context();
|
||||
@@ -545,6 +556,17 @@ trait VipServicePackages {
|
||||
return $symbol . $n;
|
||||
}
|
||||
|
||||
private function parse_vip_money_minor($value): int {
|
||||
if (!is_string($value)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance_minor($value);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function format_expires_label(int $expires, int $level): string {
|
||||
|
||||
@@ -143,7 +143,7 @@ trait VipServicePurchase {
|
||||
? $this->parse_level_products((string) ($levels[$lv]['products_raw'] ?? ''))
|
||||
: [];
|
||||
foreach ($packages as $idx => $pkg) {
|
||||
$base_price = (float) $pkg['price'];
|
||||
$base_price = (string) $pkg['price'];
|
||||
$days = (int) $pkg['days'];
|
||||
$action = 'pay';
|
||||
if ($level > 0 && $lv === $level) {
|
||||
@@ -157,9 +157,11 @@ trait VipServicePurchase {
|
||||
continue;
|
||||
}
|
||||
|
||||
$show_price = (float) ($priced['show_price'] ?? 0);
|
||||
if ($show_price <= 0 && !empty($pkg['show_price']) && $action === 'pay') {
|
||||
$show_price = (float) $pkg['show_price'];
|
||||
$show_price = (string) ($priced['show_price'] ?? '0.00');
|
||||
$show_minor = $this->parse_vip_money_minor($show_price);
|
||||
if ($show_minor <= 0 && $this->parse_vip_money_minor($pkg['show_price'] ?? '0') > 0 && $action === 'pay') {
|
||||
$show_price = (string) $pkg['show_price'];
|
||||
$show_minor = $this->parse_vip_money_minor($show_price);
|
||||
}
|
||||
$discount_tag = (string) ($priced['tag'] ?? '');
|
||||
if ($discount_tag === '' && $action === 'pay' && !empty($pkg['tag'])) {
|
||||
@@ -178,7 +180,7 @@ trait VipServicePurchase {
|
||||
'level' => $lv,
|
||||
'index' => $idx + 1,
|
||||
'action' => $action,
|
||||
'price' => (float) $priced['price'],
|
||||
'price' => (string) $priced['price'],
|
||||
'base_price' => $base_price,
|
||||
'show_price' => $show_price,
|
||||
'discount_tag' => $discount_tag,
|
||||
@@ -186,8 +188,8 @@ trait VipServicePurchase {
|
||||
'time' => (int) ($pkg['time'] ?? 0),
|
||||
'unit' => (string) ($pkg['unit'] ?? 'day'),
|
||||
'title' => (string) $pkg['title'],
|
||||
'price_label' => $this->format_cash_label((float) $priced['price']),
|
||||
'show_price_label' => $show_price > 0
|
||||
'price_label' => $this->format_cash_label((string) $priced['price']),
|
||||
'show_price_label' => $show_minor > 0
|
||||
? $this->format_cash_label($show_price)
|
||||
: '',
|
||||
'days_label' => $days_label,
|
||||
@@ -230,12 +232,8 @@ trait VipServicePurchase {
|
||||
}
|
||||
}
|
||||
|
||||
$points_balance = ($user_id > 0 && class_exists('\AKSRT\Credits\Credits'))
|
||||
? (int) \AKSRT\Credits\Credits::balance($user_id)
|
||||
: 0;
|
||||
$wallet_balance = ($user_id > 0 && class_exists('\AKSRT\Credits\Balance') && \AKSRT\Credits\Balance::enabled())
|
||||
? (float) \AKSRT\Credits\Balance::balance($user_id)
|
||||
: 0.0;
|
||||
$points_balance = $user_id > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS) : 0;
|
||||
$wallet_balance = $user_id > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : '0.00';
|
||||
$methods = $this->available_payment_methods();
|
||||
$currency_name = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt');
|
||||
|
||||
@@ -304,7 +302,7 @@ trait VipServicePurchase {
|
||||
/**
|
||||
* @return array|\WP_Error
|
||||
*/
|
||||
public function purchase(int $user_id, int $package_id, string $payment_method = 'balance') {
|
||||
public function purchase(int $user_id, int $package_id, string $payment_method = 'balance', string $operation_key = '') {
|
||||
if ($user_id <= 0) {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录后再开通会员。', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
@@ -322,8 +320,9 @@ trait VipServicePurchase {
|
||||
$level = (int) ($pkg['level'] ?? 0);
|
||||
$index = (int) ($pkg['index'] ?? 0);
|
||||
$action = sanitize_key((string) ($pkg['action'] ?? 'pay'));
|
||||
$amount = round((float) ($pkg['price'] ?? 0), 2);
|
||||
$base_price = (float) ($pkg['base_price'] ?? $amount);
|
||||
$amount = is_string($pkg['price'] ?? null) ? (string) $pkg['price'] : '';
|
||||
$amount_minor = $this->parse_vip_money_minor($amount);
|
||||
$base_price = is_string($pkg['base_price'] ?? null) ? (string) $pkg['base_price'] : $amount;
|
||||
$days = (int) ($pkg['days'] ?? 0);
|
||||
$pkg_time = array_key_exists('time', $pkg) ? (int) $pkg['time'] : $days;
|
||||
$pkg_unit = sanitize_key((string) ($pkg['unit'] ?? 'day'));
|
||||
@@ -341,9 +340,10 @@ trait VipServicePurchase {
|
||||
return new \WP_Error('aksrt_invalid_package', __('套餐无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
if ($amount <= 0) {
|
||||
if ($amount_minor <= 0) {
|
||||
return new \WP_Error('aksrt_invalid_price', __('套餐价格无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$amount_decimal = \AKSRT\Ledger\AssetAmount::decimal_from_minor($amount_minor);
|
||||
|
||||
if (!class_exists('\AKSRT\Payment\Order') || !class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
return new \WP_Error('aksrt_pay_unavailable', __('支付系统不可用', 'aksrt'), ['status' => 500]);
|
||||
@@ -370,21 +370,63 @@ trait VipServicePurchase {
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$expires_at = \AKSRT\Settings\PaymentRuntime::get_order_expiry_at();
|
||||
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$existing = $operation_key !== '' ? $repo->get_by_request_key($operation_key) : null;
|
||||
$is_new_order = !is_array($existing);
|
||||
if (is_array($existing)) {
|
||||
$existing_meta = json_decode((string) ($existing['note'] ?? ''), true);
|
||||
$existing_meta = is_array($existing_meta) ? $existing_meta : [];
|
||||
if ((int) ($existing['user_id'] ?? 0) !== $user_id
|
||||
|| sanitize_key((string) ($existing['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP
|
||||
|| (int) ($existing['product_id'] ?? 0) !== $package_id
|
||||
|| (string) ($existing['amount'] ?? '') !== $amount_decimal
|
||||
|| strtoupper((string) ($existing['currency'] ?? '')) !== 'CNY'
|
||||
|| sanitize_key((string) ($existing['payment_method'] ?? '')) !== $payment_method
|
||||
|| (int) ($existing_meta['level'] ?? 0) !== $level
|
||||
|| (int) ($existing_meta['days'] ?? 0) !== $days
|
||||
|| (int) ($existing_meta['time'] ?? 0) !== $pkg_time
|
||||
|| sanitize_key((string) ($existing_meta['unit'] ?? '')) !== $pkg_unit
|
||||
|| sanitize_key((string) ($existing_meta['action'] ?? '')) !== $action
|
||||
) {
|
||||
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他会员购买请求', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$order_number = (string) $existing['order_number'];
|
||||
} else {
|
||||
$order_number = $repo->create([
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP,
|
||||
'product_id' => $package_id,
|
||||
'amount' => $amount,
|
||||
'amount' => $amount_decimal,
|
||||
'currency' => 'CNY',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => $payment_method,
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => $expires_at,
|
||||
]);
|
||||
'request_key' => $operation_key,
|
||||
]);
|
||||
}
|
||||
if ($order_number === '') {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
if (is_array($existing) && in_array((string) ($existing['status'] ?? ''), [\AKSRT\Payment\Order::STATUS_PAID, \AKSRT\Payment\Order::STATUS_DELIVERED, \AKSRT\Payment\Order::STATUS_COMPLETED], true)) {
|
||||
$ledger_key = \AKSRT\Ledger\OperationKey::deterministic('order-payment', [$order_number]);
|
||||
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $ledger_key, 'page' => 1, 'per_page' => 1]);
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order_number' => $order_number,
|
||||
'amount' => $amount_decimal,
|
||||
'level' => $level,
|
||||
'days' => $days,
|
||||
'action' => $action,
|
||||
'transaction_id' => (int) ($history['items'][0]['id'] ?? 0),
|
||||
'replayed' => true,
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('会员购买已完成', 'aksrt'),
|
||||
'reload' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
// Build the request-local order context from the same server-derived
|
||||
// package used for validation. PaymentOrchestrator still reloads the
|
||||
// persisted row under its attempt lock before touching the gateway.
|
||||
@@ -395,7 +437,7 @@ trait VipServicePurchase {
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP,
|
||||
'product_id' => $package_id,
|
||||
'amount' => $amount,
|
||||
'amount' => $amount_decimal,
|
||||
'currency' => 'CNY',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => $payment_method,
|
||||
@@ -430,7 +472,7 @@ trait VipServicePurchase {
|
||||
return new \WP_Error(
|
||||
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
|
||||
(string) ($payment_result['message'] ?? __('支付失败', 'aksrt')),
|
||||
['status' => 402, 'order_number' => $order_number]
|
||||
['status' => (int) ($payment_result['status'] ?? 400), 'order_number' => $order_number]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -442,10 +484,12 @@ trait VipServicePurchase {
|
||||
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
|
||||
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
|
||||
'order_number' => $order_number,
|
||||
'amount' => $amount,
|
||||
'amount' => $amount_decimal,
|
||||
'message' => $payment_result['message'],
|
||||
'reload' => false,
|
||||
'poll' => true,
|
||||
'transaction_id' => 0,
|
||||
'replayed' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -454,10 +498,13 @@ trait VipServicePurchase {
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order_number' => $order_number,
|
||||
'amount' => $amount,
|
||||
'amount' => $amount_decimal,
|
||||
'level' => $level,
|
||||
'days' => $days,
|
||||
'action' => $action,
|
||||
'transaction_id' => (int) ($payment_result['ledger_transaction_id'] ?? 0),
|
||||
'replayed' => !empty($payment_result['replayed']),
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => $action === 'renew'
|
||||
? __('会员续费成功', 'aksrt')
|
||||
: ($action === 'upgrade' ? __('会员升级成功', 'aksrt') : __('会员开通成功', 'aksrt')),
|
||||
@@ -474,16 +521,16 @@ trait VipServicePurchase {
|
||||
* @param string $order_number
|
||||
* @param array $paid_order
|
||||
*/
|
||||
public function on_order_paid(string $order_number, array $paid_order): void {
|
||||
public function fulfill_paid_order(string $order_number, array $paid_order) {
|
||||
if (sanitize_key((string) ($paid_order['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (sanitize_key((string) ($paid_order['payment_method'] ?? '')) === 'card_pass') {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
$user_id = (int) ($paid_order['user_id'] ?? 0);
|
||||
if ($user_id <= 0) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
$meta = [];
|
||||
@@ -497,7 +544,7 @@ trait VipServicePurchase {
|
||||
$meta = $paid_order['meta_payload'];
|
||||
}
|
||||
if (!empty($meta['card_pass']) || sanitize_key((string) ($meta['vip_pay_type'] ?? '')) === 'card_pass') {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
$level = (int) ($meta['level'] ?? 0);
|
||||
@@ -561,13 +608,20 @@ trait VipServicePurchase {
|
||||
$fulfilled = (int) get_user_meta($user_id, 'aksrt_vip_level', true) >= max($previous_level, $level)
|
||||
&& ($new_expiry === 0 || $days <= 0 || $new_expiry >= max($previous_expiry, time()) + ($days * DAY_IN_SECONDS));
|
||||
}
|
||||
if ($fulfilled && class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
|
||||
if (!$fulfilled) {
|
||||
return new \WP_Error('aksrt_vip_fulfillment_failed', __('会员权益发放失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$completed = \AKSRT\Payment\OrderRepository::get_instance()->update_status(
|
||||
$order_number,
|
||||
\AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
['note' => __('会员权益已发放。', 'aksrt')]
|
||||
);
|
||||
if (!$completed) {
|
||||
return new \WP_Error('aksrt_vip_fulfillment_failed', __('会员订单完成失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -673,16 +727,13 @@ trait VipServicePurchase {
|
||||
*
|
||||
* @return array|\WP_Error
|
||||
*/
|
||||
public function exchange_with_points(int $user_id, int $package_id) {
|
||||
public function exchange_with_points(int $user_id, int $package_id, string $operation_key = '') {
|
||||
if ($user_id <= 0) {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录后再兑换。', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
if (!class_exists('\AKSRT\Credits\Credits')) {
|
||||
return new \WP_Error('aksrt_credits_unavailable', __('积分系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$catalog = $this->get_catalog($user_id);
|
||||
if (array_key_exists('credits_enabled', $catalog) && empty($catalog['credits_enabled'])) {
|
||||
return new \WP_Error('aksrt_credits_disabled', __('积分功能未启用', 'aksrt'), ['status' => 400]);
|
||||
return new \WP_Error('aksrt_points_disabled', __('积分功能未启用', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$points_ex = is_array($catalog['points_exchange'] ?? null) ? $catalog['points_exchange'] : [];
|
||||
if (empty($points_ex['enabled'])) {
|
||||
@@ -719,7 +770,7 @@ trait VipServicePurchase {
|
||||
|
||||
$balance = (int) ($catalog['user_points'] ?? 0);
|
||||
if ($balance < $cost) {
|
||||
return new \WP_Error('aksrt_credits_insufficient', __('积分不足,暂时无法兑换', 'aksrt'), [
|
||||
return new \WP_Error('aksrt_points_insufficient', __('积分不足,暂时无法兑换', 'aksrt'), [
|
||||
'status' => 402,
|
||||
'balance' => $balance,
|
||||
'cost' => $cost,
|
||||
@@ -741,62 +792,91 @@ trait VipServicePurchase {
|
||||
'action' => 'points_exchange',
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP,
|
||||
'product_id' => $package_id,
|
||||
'amount' => (float) $cost,
|
||||
'currency' => 'points',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => 'points',
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
|
||||
]);
|
||||
if ($order_number === '') {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$existing = $operation_key !== '' ? $repo->get_by_request_key($operation_key) : null;
|
||||
$is_new_order = !is_array($existing);
|
||||
if (is_array($existing)) {
|
||||
$existing_meta = json_decode((string) ($existing['note'] ?? ''), true);
|
||||
$existing_meta = is_array($existing_meta) ? $existing_meta : [];
|
||||
if ((int) ($existing['user_id'] ?? 0) !== $user_id
|
||||
|| sanitize_key((string) ($existing['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP
|
||||
|| (int) ($existing['product_id'] ?? 0) !== $package_id
|
||||
|| (string) ($existing['currency'] ?? '') !== 'points'
|
||||
|| (int) ($existing['amount'] ?? 0) !== $cost
|
||||
|| sanitize_key((string) ($existing['payment_method'] ?? '')) !== 'points'
|
||||
|| (int) ($existing_meta['level'] ?? 0) !== $level
|
||||
|| (int) ($existing_meta['days'] ?? 0) !== $days
|
||||
) {
|
||||
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他会员兑换请求', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$order_number = (string) $existing['order_number'];
|
||||
} else {
|
||||
$order_number = $repo->allocate_order_number();
|
||||
}
|
||||
if ($order_number === '') {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$guard = static function () use ($is_new_order, $order_number, $user_id, $package_id, $cost) {
|
||||
if ($is_new_order) {
|
||||
return true;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('order');
|
||||
$current = $wpdb->get_row($wpdb->prepare("SELECT user_id, product_id, amount, currency, status FROM {$table} WHERE order_number = %s FOR UPDATE", $order_number), ARRAY_A);
|
||||
if (!is_array($current)
|
||||
|| (int) ($current['user_id'] ?? 0) !== $user_id
|
||||
|| (int) ($current['product_id'] ?? 0) !== $package_id
|
||||
|| (int) ($current['amount'] ?? 0) !== $cost
|
||||
|| (string) ($current['currency'] ?? '') !== 'points'
|
||||
|| !in_array((string) ($current['status'] ?? ''), [\AKSRT\Payment\Order::STATUS_CREATED, \AKSRT\Payment\Order::STATUS_PENDING], true)
|
||||
) {
|
||||
return new \WP_Error('aksrt_order_confirm_failed', __('订单状态或金额已变化', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
$lock = class_exists('\AKSRT\Payment\PaymentAttemptLock')
|
||||
? \AKSRT\Payment\PaymentAttemptLock::acquire($order_number)
|
||||
: false;
|
||||
if ($lock === false) {
|
||||
return new \WP_Error('aksrt_pay_locked', __('该订单正在发起支付,请稍后重试。', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
|
||||
try {
|
||||
$deducted = \AKSRT\Credits\Credits::deduct(
|
||||
$user_id,
|
||||
$cost,
|
||||
'vip_points_exchange',
|
||||
['order' => $order_number, 'level' => $level, 'days' => $days],
|
||||
sprintf(__('积分兑换会员 · %s', 'aksrt'), (string) ($match['title'] ?? ''))
|
||||
);
|
||||
if (is_wp_error($deducted) || $deducted === false) {
|
||||
\AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_CANCELLED, [
|
||||
'note' => __('积分扣除失败,自动取消', 'aksrt'),
|
||||
$ledger = \AKSRT\Ledger\LedgerService::get_instance();
|
||||
$deducted = $ledger->debit(
|
||||
$user_id,
|
||||
\AKSRT\Ledger\AssetType::POINTS,
|
||||
$cost,
|
||||
$operation_key,
|
||||
'vip_points_exchange',
|
||||
['type' => 'order', 'key' => $order_number],
|
||||
sprintf(__('积分兑换会员 · %s', 'aksrt'), (string) ($match['title'] ?? '')),
|
||||
$user_id,
|
||||
['level' => $level, 'days' => $days, 'package_id' => $package_id],
|
||||
static function () use ($is_new_order, $repo, $order_number, $operation_key, $user_id, $package_id, $cost, $note) {
|
||||
if ($is_new_order) {
|
||||
$created = $repo->create([
|
||||
'order_number' => $order_number,
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP,
|
||||
'product_id' => $package_id,
|
||||
'amount' => $cost,
|
||||
'currency' => 'points',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => 'points',
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
|
||||
'request_key' => $operation_key,
|
||||
]);
|
||||
if ($created !== $order_number) {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
}
|
||||
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||
'payment_method' => 'points',
|
||||
'payment_transaction_id' => 'points_' . $order_number,
|
||||
'note' => sprintf(__('积分兑换会员 %d 积分。', 'aksrt'), $cost),
|
||||
'require_transition' => true,
|
||||
]);
|
||||
$msg = is_wp_error($deducted) ? $deducted->get_error_message() : __('积分扣除失败', 'aksrt');
|
||||
return new \WP_Error('aksrt_credits_deduct_failed', $msg ?: __('积分扣除失败', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||
'payment_method' => 'points',
|
||||
'payment_transaction_id' => 'points_' . $order_number,
|
||||
'note' => sprintf(__('积分兑换会员 %d 积分。', 'aksrt'), $cost),
|
||||
'require_transition' => true,
|
||||
]);
|
||||
if (!$paid) {
|
||||
\AKSRT\Credits\Credits::add(
|
||||
$user_id,
|
||||
$cost,
|
||||
'vip_points_exchange_refund',
|
||||
['order' => $order_number],
|
||||
__('会员兑换失败退款', 'aksrt')
|
||||
);
|
||||
return new \WP_Error('aksrt_order_confirm_failed', __('兑换确认失败,已尝试退回积分', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
} finally {
|
||||
\AKSRT\Payment\PaymentAttemptLock::release($order_number, $lock);
|
||||
return $paid ? true : new \WP_Error('aksrt_order_confirm_failed', __('兑换确认失败', 'aksrt'), ['status' => 500]);
|
||||
},
|
||||
$guard
|
||||
);
|
||||
if (is_wp_error($deducted)) {
|
||||
return $deducted;
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -806,7 +886,9 @@ trait VipServicePurchase {
|
||||
'points' => $cost,
|
||||
'level' => $level,
|
||||
'days' => $days,
|
||||
'balance' => \AKSRT\Credits\Credits::balance($user_id),
|
||||
'balance' => $ledger->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
'transaction_id' => $deducted->transaction_id,
|
||||
'replayed' => $deducted->replayed,
|
||||
'message' => __('兑换成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
],
|
||||
|
||||
@@ -22,6 +22,10 @@ trait VipServiceRest {
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_purchase'],
|
||||
'permission_callback' => [$this, 'check_rest_nonce'],
|
||||
'args' => [
|
||||
'package_id' => ['type' => 'integer', 'required' => true, 'minimum' => 1],
|
||||
'payment_method' => ['type' => 'string', 'sanitize_callback' => 'sanitize_key'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -29,6 +33,7 @@ trait VipServiceRest {
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_points_exchange'],
|
||||
'permission_callback' => [$this, 'check_rest_nonce'],
|
||||
'args' => ['package_id' => ['type' => 'integer', 'required' => true, 'minimum' => 1]],
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/pay/vip/modal', [
|
||||
@@ -112,12 +117,16 @@ trait VipServiceRest {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$package_id = (int) ($params['package_id'] ?? 0);
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'vip-purchase', $uid);
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $operation_key;
|
||||
}
|
||||
$payment_method = sanitize_key((string) ($params['payment_method'] ?? 'balance'));
|
||||
if ($payment_method === '' || $payment_method === 'cash') {
|
||||
$payment_method = 'balance';
|
||||
}
|
||||
|
||||
$result = $this->purchase($uid, $package_id, $payment_method);
|
||||
$result = $this->purchase($uid, $package_id, $payment_method, $operation_key);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
@@ -147,7 +156,11 @@ trait VipServiceRest {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$package_id = (int) ($params['package_id'] ?? 0);
|
||||
$result = $this->exchange_with_points($uid, $package_id);
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'vip-points-purchase', $uid);
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $operation_key;
|
||||
}
|
||||
$result = $this->exchange_with_points($uid, $package_id, $operation_key);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
+34
-20
@@ -43,10 +43,11 @@ class Visibility {
|
||||
? ((($priced['modo'] ?? '0') === 'points') ? 'points' : '0')
|
||||
: ((($pay_meta['pay_modo'] ?? '0') === 'points') ? 'points' : '0');
|
||||
$price = is_array($priced)
|
||||
? (float) ($priced['amount'] ?? 0)
|
||||
? ($priced['amount'] ?? ($pay_modo === 'points' ? 0 : '0.00'))
|
||||
: (($pay_modo === 'points')
|
||||
? (float) ($pay_meta['points_price'] ?? 0)
|
||||
: (float) ($pay_meta['price'] ?? 0));
|
||||
? (int) ($pay_meta['points_price'] ?? 0)
|
||||
: (string) ($pay_meta['price'] ?? '0.00'));
|
||||
$price_minor = self::asset_minor($price, $pay_modo);
|
||||
$state = [
|
||||
'post_id' => $resource_post instanceof \WP_Post ? (int) $resource_post->ID : 0,
|
||||
'pay_type' => $pay_type,
|
||||
@@ -75,7 +76,7 @@ class Visibility {
|
||||
return $state;
|
||||
}
|
||||
|
||||
$has_free_access = $price <= 0 && $pay_type !== \AKSRT\Core\DomainModel::PAY_TYPE_VIP;
|
||||
$has_free_access = $price_minor <= 0 && $pay_type !== \AKSRT\Core\DomainModel::PAY_TYPE_VIP;
|
||||
$pay_limit = (int) ($pay_meta['pay_limit'] ?? 0);
|
||||
if (!in_array($pay_limit, [0, 1, 2], true)) {
|
||||
$pay_limit = 0;
|
||||
@@ -120,19 +121,19 @@ class Visibility {
|
||||
if ($pay_type === \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD) {
|
||||
// Cash: order gate only. Points: cashier once → paid order zeros cost.
|
||||
$download_cost = 0;
|
||||
if ($pay_modo === 'points' && $price > 0 && !$has_paid_order) {
|
||||
$download_cost = (int) ceil($price);
|
||||
if ($pay_modo === 'points' && $price_minor > 0 && !$has_paid_order) {
|
||||
$download_cost = $price_minor;
|
||||
}
|
||||
if ($allowed_member && $price <= 0) {
|
||||
if ($allowed_member && $price_minor <= 0) {
|
||||
$download_cost = 0;
|
||||
}
|
||||
$can_download = function_exists('aksrt_user_can') && aksrt_user_can('resource_download', $uid, (int) $resource_post->ID);
|
||||
$state['download_cost'] = max(0, $download_cost);
|
||||
$state['need_login'] = !$can_download && $uid <= 0;
|
||||
$state['need_purchase'] = $allowed_member && $price > 0 && !$has_paid_order;
|
||||
$state['has_paid_access'] = $allowed_member && ($has_paid_order || $price <= 0);
|
||||
$state['need_purchase'] = $allowed_member && $price_minor > 0 && !$has_paid_order;
|
||||
$state['has_paid_access'] = $allowed_member && ($has_paid_order || $price_minor <= 0);
|
||||
$state['can_view'] = true;
|
||||
$state['can_download'] = $can_download && $allowed_member && ($has_paid_order || $price <= 0);
|
||||
$state['can_download'] = $can_download && $allowed_member && ($has_paid_order || $price_minor <= 0);
|
||||
if (!$allowed_member) {
|
||||
$state['can_download'] = false;
|
||||
$state['has_paid_access'] = false;
|
||||
@@ -144,7 +145,7 @@ class Visibility {
|
||||
|
||||
// reading/gallery/video with pay_limit
|
||||
if ($allowed_member) {
|
||||
$state['has_paid_access'] = $has_paid_order || $price <= 0;
|
||||
$state['has_paid_access'] = $has_paid_order || $price_minor <= 0;
|
||||
$state['need_purchase'] = !$state['has_paid_access'];
|
||||
$state['need_vip'] = false;
|
||||
} else {
|
||||
@@ -160,8 +161,8 @@ class Visibility {
|
||||
if ($pay_type === \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD) {
|
||||
// legacy: cash = order unlock; points = one-shot cashier order (not per-download deduct).
|
||||
$download_cost = 0;
|
||||
if ($pay_modo === 'points' && $price > 0 && !$has_paid_order) {
|
||||
$download_cost = (int) ceil($price);
|
||||
if ($pay_modo === 'points' && $price_minor > 0 && !$has_paid_order) {
|
||||
$download_cost = $price_minor;
|
||||
}
|
||||
$state['download_cost'] = max(0, $download_cost);
|
||||
|
||||
@@ -169,10 +170,10 @@ class Visibility {
|
||||
$state['need_login'] = !$can_download && $uid <= 0;
|
||||
$state['require_captcha'] = function_exists('aksrt_is_captcha_enabled_for') && aksrt_is_captcha_enabled_for('download');
|
||||
|
||||
$state['need_purchase'] = !$has_paid_order && $price > 0;
|
||||
$state['has_paid_access'] = $has_paid_order || $price <= 0 || $has_free_access;
|
||||
$state['need_purchase'] = !$has_paid_order && $price_minor > 0;
|
||||
$state['has_paid_access'] = $has_paid_order || $price_minor <= 0 || $has_free_access;
|
||||
$state['can_view'] = true;
|
||||
$state['can_download'] = $can_download && ($has_paid_order || $price <= 0);
|
||||
$state['can_download'] = $can_download && ($has_paid_order || $price_minor <= 0);
|
||||
|
||||
return $state;
|
||||
}
|
||||
@@ -190,7 +191,7 @@ class Visibility {
|
||||
$state['need_purchase'] = !$has_free_access && !$has_paid_order;
|
||||
$state['has_paid_access'] = $has_free_access || $has_paid_order;
|
||||
// VIP free tier for reading etc. via amount.
|
||||
if (!$state['has_paid_access'] && $price <= 0 && $pay_type !== \AKSRT\Core\DomainModel::PAY_TYPE_VIP) {
|
||||
if (!$state['has_paid_access'] && $price_minor <= 0 && $pay_type !== \AKSRT\Core\DomainModel::PAY_TYPE_VIP) {
|
||||
$state['has_paid_access'] = true;
|
||||
$state['need_purchase'] = false;
|
||||
}
|
||||
@@ -253,11 +254,10 @@ class Visibility {
|
||||
// Cash or points: require cashier purchase (resource order) before download token.
|
||||
if (!empty($state['need_purchase']) && empty($state['has_paid_access'])) {
|
||||
$pay_modo = (string) ($state['pay_modo'] ?? '0');
|
||||
$price = (float) ($state['price'] ?? 0);
|
||||
return new \WP_Error('aksrt_need_purchase', __('请先购买后再下载。', 'aksrt'), [
|
||||
'status' => 402,
|
||||
'pay_modo' => $pay_modo,
|
||||
'amount' => $pay_modo === 'points' ? (int) ceil($price) : $price,
|
||||
'amount' => $state['price'] ?? ($pay_modo === 'points' ? 0 : '0.00'),
|
||||
'post_id' => (int) ($state['post_id'] ?? 0),
|
||||
]);
|
||||
}
|
||||
@@ -270,7 +270,7 @@ class Visibility {
|
||||
return $over;
|
||||
}
|
||||
$m = aksrt_get_pay_meta($resource_post);
|
||||
return max(0.0, (float) ($m['download_limit_over_price'] ?? 0));
|
||||
return $m['download_limit_over_price'] ?? 0;
|
||||
};
|
||||
add_filter('aksrt_download_limit_over_price', $over_cb, 10, 2);
|
||||
$quota = aksrt_check_free_download_quota($uid, $cost);
|
||||
@@ -340,6 +340,20 @@ class Visibility {
|
||||
return $html;
|
||||
}
|
||||
|
||||
private static function asset_minor($value, string $pay_modo): int {
|
||||
if ($pay_modo === 'points') {
|
||||
return is_int($value) ? max(0, $value) : 0;
|
||||
}
|
||||
if (!is_string($value)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance_minor($value);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function is_unlocked(string $mode, int $pid): bool {
|
||||
switch ($mode) {
|
||||
case 'comment':
|
||||
|
||||
@@ -14,7 +14,7 @@ class DBMigrator {
|
||||
use DBMigratorVip;
|
||||
|
||||
/** Current schema version; changing it re-runs dbDelta. */
|
||||
const SCHEMA_VERSION = '1.24.0';
|
||||
const SCHEMA_VERSION = '2.0.0';
|
||||
|
||||
const OPT_KEY = 'aksrt_db_schema_version';
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ trait DBMigratorIdentity {
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
application_uuid CHAR(36) NOT NULL,
|
||||
client_request_id CHAR(36) NOT NULL,
|
||||
request_payload_hash CHAR(64) NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
channel VARCHAR(20) NOT NULL DEFAULT 'cloud',
|
||||
@@ -59,7 +60,7 @@ trait DBMigratorIdentity {
|
||||
KEY idx_status_updated (status, updated_at),
|
||||
KEY idx_id_number_hash (id_number_hash),
|
||||
KEY idx_company_code_hash (company_code_hash)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($application);
|
||||
|
||||
$event = "CREATE TABLE {$prefix}aksrt_identity_event (
|
||||
@@ -81,7 +82,7 @@ trait DBMigratorIdentity {
|
||||
UNIQUE KEY uniq_dedupe_key (dedupe_key),
|
||||
KEY idx_application_created (application_id, created_at),
|
||||
KEY idx_source_external (source, external_event_id)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,43 +20,55 @@ trait DBMigratorPart2 {
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
$prefix = $wpdb->prefix;
|
||||
|
||||
// ---------- 1. 积分流水 ----------
|
||||
$sql_credit_log = "CREATE TABLE {$prefix}aksrt_credit_log (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
// ---------- 1. 统一资产账户与不可变账本 ----------
|
||||
$sql_asset_account = "CREATE TABLE {$prefix}aksrt_asset_account (
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
delta INT NOT NULL,
|
||||
balance_after INT NOT NULL,
|
||||
reason VARCHAR(64) NOT NULL,
|
||||
remark VARCHAR(255) DEFAULT NULL,
|
||||
ref_type VARCHAR(32) DEFAULT NULL,
|
||||
ref_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
ip VARCHAR(64) DEFAULT NULL,
|
||||
ua VARCHAR(255) DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_user_time (user_id, created_at),
|
||||
KEY idx_reason (reason)
|
||||
) {$charset_collate};";
|
||||
dbDelta($sql_credit_log);
|
||||
asset VARCHAR(16) NOT NULL,
|
||||
balance_minor BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
version BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME(6) NOT NULL,
|
||||
PRIMARY KEY (user_id, asset),
|
||||
KEY idx_asset_balance (asset, balance_minor)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_asset_account);
|
||||
|
||||
// ---------- 1b. 现金余额流水(站内钱包,与积分分离) ----------
|
||||
$sql_balance_log = "CREATE TABLE {$prefix}aksrt_balance_log (
|
||||
$sql_asset_transaction = "CREATE TABLE {$prefix}aksrt_asset_transaction (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
delta DECIMAL(18,2) NOT NULL,
|
||||
balance_after DECIMAL(18,2) NOT NULL,
|
||||
operation_key VARCHAR(191) NOT NULL,
|
||||
payload_hash CHAR(64) NOT NULL,
|
||||
reason VARCHAR(64) NOT NULL,
|
||||
remark VARCHAR(255) DEFAULT NULL,
|
||||
ref_type VARCHAR(32) DEFAULT NULL,
|
||||
ref_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
ref_key VARCHAR(191) DEFAULT NULL,
|
||||
actor_user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
metadata LONGTEXT DEFAULT NULL,
|
||||
ip VARCHAR(64) DEFAULT NULL,
|
||||
ua VARCHAR(255) DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_user_time (user_id, created_at),
|
||||
KEY idx_reason (reason)
|
||||
) {$charset_collate};";
|
||||
dbDelta($sql_balance_log);
|
||||
UNIQUE KEY uniq_operation_key (operation_key),
|
||||
KEY idx_reason_time (reason, created_at),
|
||||
KEY idx_reference (ref_type, ref_key)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_asset_transaction);
|
||||
|
||||
$sql_asset_entry = "CREATE TABLE {$prefix}aksrt_asset_entry (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
transaction_id BIGINT UNSIGNED NOT NULL,
|
||||
line_no SMALLINT UNSIGNED NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
asset VARCHAR(16) NOT NULL,
|
||||
entry_type VARCHAR(32) NOT NULL,
|
||||
delta_minor BIGINT NOT NULL,
|
||||
balance_after_minor BIGINT UNSIGNED NOT NULL,
|
||||
counterparty_user_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
remark VARCHAR(255) DEFAULT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_transaction_line (transaction_id, line_no),
|
||||
KEY idx_user_asset_time (user_id, asset, created_at, id),
|
||||
KEY idx_transaction (transaction_id)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_asset_entry);
|
||||
|
||||
// ---------- 2. 签到 ----------
|
||||
$sql_signin = "CREATE TABLE {$prefix}aksrt_signin_log (
|
||||
@@ -68,7 +80,7 @@ trait DBMigratorPart2 {
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_user_date (user_id, signin_date),
|
||||
KEY idx_user (user_id)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_signin);
|
||||
|
||||
// ---------- 3. 下载 token ----------
|
||||
@@ -88,7 +100,7 @@ trait DBMigratorPart2 {
|
||||
KEY idx_expires (expires_at),
|
||||
KEY idx_user (user_id),
|
||||
KEY idx_post (post_id)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_dl_token);
|
||||
|
||||
// ---------- 5. 用户通知收件箱 ----------
|
||||
@@ -105,13 +117,14 @@ trait DBMigratorPart2 {
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_user_created (user_id, created_at),
|
||||
KEY idx_user_read (user_id, is_read)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_notification);
|
||||
|
||||
// ---------- 8. 订单表 ----------
|
||||
$sql_order = "CREATE TABLE {$prefix}aksrt_order (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
order_number VARCHAR(32) NOT NULL,
|
||||
request_key VARCHAR(191) DEFAULT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
product_type VARCHAR(32) NOT NULL DEFAULT '',
|
||||
product_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
@@ -131,12 +144,13 @@ trait DBMigratorPart2 {
|
||||
note TEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_order_number (order_number),
|
||||
UNIQUE KEY uniq_request_key (request_key),
|
||||
KEY idx_user (user_id),
|
||||
KEY idx_user_status_created (user_id, status, created_at),
|
||||
KEY idx_status (status),
|
||||
KEY idx_created (created_at),
|
||||
KEY idx_product (product_type, product_id)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_order);
|
||||
|
||||
// ---------- 9. 作者收益台账 ----------
|
||||
@@ -168,7 +182,7 @@ trait DBMigratorPart2 {
|
||||
KEY idx_currency (currency),
|
||||
KEY idx_status (status),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_revenue);
|
||||
|
||||
// ---------- 10. 提现台账 ----------
|
||||
@@ -180,15 +194,17 @@ trait DBMigratorPart2 {
|
||||
method VARCHAR(32) NOT NULL DEFAULT '',
|
||||
account_info TEXT DEFAULT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
ledger_transaction_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
processed_at DATETIME DEFAULT NULL,
|
||||
note TEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_ledger_transaction (ledger_transaction_id),
|
||||
KEY idx_user (user_id),
|
||||
KEY idx_user_created (user_id, created_at),
|
||||
KEY idx_status (status),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_withdrawal);
|
||||
|
||||
// ---------- 11. 举报工单 ----------
|
||||
@@ -208,7 +224,7 @@ trait DBMigratorPart2 {
|
||||
KEY idx_target (target_type, target_id),
|
||||
KEY idx_reporter (reporter_id),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_report);
|
||||
|
||||
// ---------- 12. 用户限制/封禁 ----------
|
||||
@@ -227,7 +243,7 @@ trait DBMigratorPart2 {
|
||||
KEY idx_scope (scope),
|
||||
KEY idx_expires (expires_at),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_restriction);
|
||||
|
||||
// ── follow ──
|
||||
@@ -240,7 +256,7 @@ trait DBMigratorPart2 {
|
||||
UNIQUE KEY idx_pair (follower_id, following_id),
|
||||
KEY idx_following (following_id),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_follow);
|
||||
|
||||
// ── coupon ──
|
||||
@@ -262,7 +278,7 @@ trait DBMigratorPart2 {
|
||||
UNIQUE KEY idx_code (code),
|
||||
KEY idx_status (status),
|
||||
KEY idx_expires (expires_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_coupon);
|
||||
|
||||
// ── coupon_usage (R68) ──
|
||||
@@ -276,11 +292,11 @@ trait DBMigratorPart2 {
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_coupon (coupon_id),
|
||||
KEY idx_user (user_id),
|
||||
KEY idx_order (order_number)
|
||||
) {$charset_collate};";
|
||||
UNIQUE KEY uniq_coupon_usage_order (order_number)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_coupon_usage);
|
||||
|
||||
// ── charge_card (legacy-style typed card_pass) ──
|
||||
// ── typed charge cards ──
|
||||
$sql_charge_card = "CREATE TABLE {$prefix}aksrt_charge_card (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
card VARCHAR(60) NOT NULL DEFAULT '',
|
||||
@@ -304,31 +320,9 @@ trait DBMigratorPart2 {
|
||||
KEY idx_used_by (used_by),
|
||||
KEY idx_order_number (order_number),
|
||||
KEY idx_expires (expires_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_charge_card);
|
||||
|
||||
// ── rebate_log ──
|
||||
$sql_rebate_log = "CREATE TABLE {$prefix}aksrt_rebate_log (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
order_number VARCHAR(32) NOT NULL DEFAULT '',
|
||||
source_user_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
type VARCHAR(32) NOT NULL DEFAULT 'order',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
settled_at DATETIME DEFAULT NULL,
|
||||
note TEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_user (user_id),
|
||||
KEY idx_order (order_number),
|
||||
KEY idx_source (source_user_id),
|
||||
KEY idx_status (status),
|
||||
KEY idx_type (type),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
dbDelta($sql_rebate_log);
|
||||
|
||||
// ── canonical Ref attribution and permanent user binding ──
|
||||
$sql_affiliate_relation = "CREATE TABLE {$prefix}aksrt_affiliate_relation (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
@@ -349,7 +343,7 @@ trait DBMigratorPart2 {
|
||||
KEY idx_status_touch (status, first_touch_at),
|
||||
KEY idx_reward_status (reward_status),
|
||||
KEY idx_reward_ip (registration_ip, rewarded_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_affiliate_relation);
|
||||
|
||||
// ── immutable commission ledger ──
|
||||
@@ -365,17 +359,20 @@ trait DBMigratorPart2 {
|
||||
adjustment_total DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
idempotency_key VARCHAR(128) NOT NULL,
|
||||
cancel_operation_key VARCHAR(191) DEFAULT NULL,
|
||||
cancel_payload_hash CHAR(64) DEFAULT NULL,
|
||||
available_at DATETIME NOT NULL,
|
||||
settled_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
note TEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_commission_idempotency (idempotency_key),
|
||||
UNIQUE KEY uniq_commission_cancel_operation (cancel_operation_key),
|
||||
KEY idx_promoter_status (promoter_id, status),
|
||||
KEY idx_order_type (order_number, order_type),
|
||||
KEY idx_available (status, available_at),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_commission_ledger);
|
||||
|
||||
// ── immutable commission adjustments (refund/debt/manual) ──
|
||||
@@ -393,7 +390,7 @@ trait DBMigratorPart2 {
|
||||
UNIQUE KEY uniq_adjustment_source (source_key),
|
||||
KEY idx_ledger (ledger_id),
|
||||
KEY idx_promoter (promoter_id)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_commission_adjustment);
|
||||
|
||||
// ── payout account snapshots ──
|
||||
@@ -409,7 +406,7 @@ trait DBMigratorPart2 {
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_user_channel (user_id, channel),
|
||||
KEY idx_user_status (user_id, status)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_payout_account);
|
||||
|
||||
// ── order_items (§6.5 多商品多商家) ──
|
||||
@@ -434,7 +431,7 @@ trait DBMigratorPart2 {
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_order (order_number),
|
||||
KEY idx_product (product_id)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_order_items);
|
||||
|
||||
self::migrate_identity_tables($prefix, $charset_collate);
|
||||
|
||||
@@ -16,8 +16,9 @@ trait DBMigratorPart3 {
|
||||
public static function table(string $name): string {
|
||||
global $wpdb;
|
||||
$allowed = [
|
||||
'credit_log' => 'aksrt_credit_log',
|
||||
'balance_log' => 'aksrt_balance_log',
|
||||
'asset_account' => 'aksrt_asset_account',
|
||||
'asset_transaction' => 'aksrt_asset_transaction',
|
||||
'asset_entry' => 'aksrt_asset_entry',
|
||||
'signin_log' => 'aksrt_signin_log',
|
||||
'dl_token' => 'aksrt_dl_token',
|
||||
'notification' => 'aksrt_notification',
|
||||
@@ -29,7 +30,6 @@ trait DBMigratorPart3 {
|
||||
'follow' => 'aksrt_follow',
|
||||
'coupon' => 'aksrt_coupon',
|
||||
'charge_card' => 'aksrt_charge_card',
|
||||
'rebate_log' => 'aksrt_rebate_log',
|
||||
'affiliate_relation' => 'aksrt_affiliate_relation',
|
||||
'commission_ledger' => 'aksrt_commission_ledger',
|
||||
'commission_adjustment' => 'aksrt_commission_adjustment',
|
||||
|
||||
@@ -36,7 +36,7 @@ trait DBMigratorVip {
|
||||
KEY idx_batch (batch_uuid),
|
||||
KEY idx_status_created (status, created_at),
|
||||
KEY idx_operation_created (operation, created_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($event);
|
||||
}
|
||||
}
|
||||
|
||||
+44
-16
@@ -22,7 +22,6 @@ class DomainModel {
|
||||
public const RESOURCE_SALES_VOLUME_KEY = '_aksrt_sales_volume';
|
||||
public const POST_VIEW_COUNT_KEY = 'aksrt_post_views';
|
||||
|
||||
public const ORDER_MIGRATED_OPTION = 'aksrt_order_migrated_to_table';
|
||||
public const RESOURCE_PAY_META_MIGRATED = 'aksrt_resource_pay_meta_migrated';
|
||||
|
||||
public const ORDER_PRODUCT_RESOURCE = 'resource';
|
||||
@@ -116,11 +115,11 @@ class DomainModel {
|
||||
return [
|
||||
'pay_type' => self::PAY_TYPE_NO,
|
||||
'pay_modo' => '0', // '0' cash | 'points' credits (legacy)
|
||||
'price' => 0.0,
|
||||
'price' => '0.00',
|
||||
'points_price' => 0,
|
||||
'original_price' => 0.0,
|
||||
'vip1_price' => 0.0,
|
||||
'vip2_price' => 0.0,
|
||||
'original_price' => '0.00',
|
||||
'vip1_price' => '0.00',
|
||||
'vip2_price' => '0.00',
|
||||
'vip1_points' => 0,
|
||||
'vip2_points' => 0,
|
||||
'pay_limit' => 0, // 0 all | 1 VIP1+ | 2 VIP2 only (legacy)
|
||||
@@ -143,11 +142,11 @@ class DomainModel {
|
||||
'free_preview' => '',
|
||||
'show_sales' => true,
|
||||
// legacy: free daily quota overbuy price (cash or points by pay_modo).
|
||||
'download_limit_over_price' => 0.0,
|
||||
'download_limit_over_price' => '0.00',
|
||||
// Content cashier: coupon + buyer-facing 推广折扣 (legacy posts_aksrtpay).
|
||||
'coupon_s' => false,
|
||||
'coupon_desc' => '',
|
||||
'pay_rebate_discount' => 0.0,
|
||||
'pay_rebate_discount' => '0.00',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -319,13 +318,13 @@ class DomainModel {
|
||||
$meta['pay_type'] = self::normalize_pay_type((string) ($raw['pay_type'] ?? $meta['pay_type']));
|
||||
$pay_modo = sanitize_key((string) ($raw['pay_modo'] ?? '0'));
|
||||
$meta['pay_modo'] = $pay_modo === 'points' ? 'points' : '0';
|
||||
$meta['price'] = max(0, (float) ($raw['price'] ?? 0));
|
||||
$meta['points_price'] = max(0, (int) ($raw['points_price'] ?? $raw['price'] ?? 0));
|
||||
$meta['original_price'] = max(0, (float) ($raw['original_price'] ?? 0));
|
||||
$meta['vip1_price'] = max(0, (float) ($raw['vip1_price'] ?? 0));
|
||||
$meta['vip2_price'] = max(0, (float) ($raw['vip2_price'] ?? 0));
|
||||
$meta['vip1_points'] = max(0, (int) ($raw['vip1_points'] ?? $raw['vip1_price'] ?? 0));
|
||||
$meta['vip2_points'] = max(0, (int) ($raw['vip2_points'] ?? $raw['vip2_price'] ?? 0));
|
||||
$meta['price'] = self::sanitize_balance_decimal($raw['price'] ?? '0');
|
||||
$meta['points_price'] = self::sanitize_points($raw['points_price'] ?? 0);
|
||||
$meta['original_price'] = self::sanitize_balance_decimal($raw['original_price'] ?? '0');
|
||||
$meta['vip1_price'] = self::sanitize_balance_decimal($raw['vip1_price'] ?? '0');
|
||||
$meta['vip2_price'] = self::sanitize_balance_decimal($raw['vip2_price'] ?? '0');
|
||||
$meta['vip1_points'] = self::sanitize_points($raw['vip1_points'] ?? 0);
|
||||
$meta['vip2_points'] = self::sanitize_points($raw['vip2_points'] ?? 0);
|
||||
$pay_limit = (int) ($raw['pay_limit'] ?? 0);
|
||||
$meta['pay_limit'] = in_array($pay_limit, [0, 1, 2], true) ? $pay_limit : 0;
|
||||
$meta['version'] = sanitize_text_field((string) ($raw['version'] ?? ''));
|
||||
@@ -347,14 +346,43 @@ class DomainModel {
|
||||
$meta['purchase_limit'] = max(0, (int) ($raw['purchase_limit'] ?? 0));
|
||||
$meta['free_preview'] = wp_kses_post((string) ($raw['free_preview'] ?? ''));
|
||||
$meta['show_sales'] = !isset($raw['show_sales']) || !empty($raw['show_sales']);
|
||||
$meta['download_limit_over_price'] = max(0, (float) ($raw['download_limit_over_price'] ?? 0));
|
||||
$meta['download_limit_over_price'] = $meta['pay_modo'] === 'points'
|
||||
? self::sanitize_points($raw['download_limit_over_price'] ?? 0)
|
||||
: self::sanitize_balance_decimal($raw['download_limit_over_price'] ?? '0');
|
||||
$meta['coupon_s'] = !empty($raw['coupon_s']);
|
||||
$meta['coupon_desc'] = sanitize_textarea_field((string) ($raw['coupon_desc'] ?? ''));
|
||||
$meta['pay_rebate_discount'] = max(0, (float) ($raw['pay_rebate_discount'] ?? 0));
|
||||
$meta['pay_rebate_discount'] = self::sanitize_balance_decimal($raw['pay_rebate_discount'] ?? '0');
|
||||
|
||||
return $meta;
|
||||
}
|
||||
|
||||
private static function sanitize_balance_decimal($value): string {
|
||||
if (!is_string($value)) {
|
||||
return '0.00';
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return '0.00';
|
||||
}
|
||||
}
|
||||
|
||||
private static function sanitize_points($value): int {
|
||||
if (is_int($value)) {
|
||||
return max(0, $value);
|
||||
}
|
||||
if (!is_string($value) || !preg_match('/^(0|[1-9]\d*)$/', trim($value))) {
|
||||
return 0;
|
||||
}
|
||||
$value = trim($value);
|
||||
if (strlen($value) > strlen((string) PHP_INT_MAX)
|
||||
|| (strlen($value) === strlen((string) PHP_INT_MAX) && strcmp($value, (string) PHP_INT_MAX) > 0)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
||||
+78
-33
@@ -67,16 +67,16 @@ trait FrontSubmitPay {
|
||||
$meta['pay_type'] = $pay_type;
|
||||
$meta['pay_modo'] = $pay_modo;
|
||||
|
||||
$min = (float) ($settings['pay_price_min'] ?? 0);
|
||||
$max = (float) ($settings['pay_price_max'] ?? 0);
|
||||
$allow_free = !empty($settings['pay_free_s']);
|
||||
$vip_manual = !empty($settings['pay_vip_price_s']);
|
||||
$vip1_disc = (int) ($settings['pay_vip_1_discount'] ?? 100);
|
||||
$vip2_disc = (int) ($settings['pay_vip_2_discount'] ?? 100);
|
||||
|
||||
if ($pay_modo === 'points') {
|
||||
$points = isset($raw['points_price']) ? (int) $raw['points_price'] : (int) ($data['points_price'] ?? 0);
|
||||
if ($points < 0) {
|
||||
$points = $this->parse_front_points($raw['points_price'] ?? $data['points_price'] ?? null);
|
||||
$min = $this->parse_front_points_limit($settings['pay_price_min'] ?? '0.00');
|
||||
$max = $this->parse_front_points_limit($settings['pay_price_max'] ?? '0.00');
|
||||
if ($points === null) {
|
||||
return ['success' => false, 'message' => __('请设置正确的积分价格', 'aksrt')];
|
||||
}
|
||||
if (!$allow_free && $points <= 0) {
|
||||
@@ -90,59 +90,64 @@ trait FrontSubmitPay {
|
||||
}
|
||||
|
||||
if ($vip_manual) {
|
||||
$vip1 = isset($raw['vip1_points']) ? (int) $raw['vip1_points'] : (int) ($raw['vip_1_points'] ?? 0);
|
||||
$vip2 = isset($raw['vip2_points']) ? (int) $raw['vip2_points'] : (int) ($raw['vip_2_points'] ?? 0);
|
||||
$vip1 = $this->parse_front_points($raw['vip1_points'] ?? $raw['vip_1_points'] ?? null);
|
||||
$vip2 = $this->parse_front_points($raw['vip2_points'] ?? $raw['vip_2_points'] ?? null);
|
||||
if ($vip1 === null || $vip2 === null) {
|
||||
return ['success' => false, 'message' => __('请设置正确的会员积分价格', 'aksrt')];
|
||||
}
|
||||
} else {
|
||||
$vip1 = (int) floor($points * $vip1_disc / 100);
|
||||
$vip2 = (int) floor($points * $vip2_disc / 100);
|
||||
$vip1 = (intdiv($points, 100) * $vip1_disc) + intdiv(($points % 100) * $vip1_disc, 100);
|
||||
$vip2 = (intdiv($points, 100) * $vip2_disc) + intdiv(($points % 100) * $vip2_disc, 100);
|
||||
}
|
||||
$vip1 = max(0, min($points, $vip1));
|
||||
$vip2 = max(0, min($vip1, $vip2));
|
||||
|
||||
$meta['points_price'] = $points;
|
||||
$meta['price'] = 0.0;
|
||||
$meta['price'] = '0.00';
|
||||
$meta['vip1_points'] = $vip1;
|
||||
$meta['vip2_points'] = $vip2;
|
||||
$meta['vip1_price'] = 0.0;
|
||||
$meta['vip2_price'] = 0.0;
|
||||
$meta['vip1_price'] = '0.00';
|
||||
$meta['vip2_price'] = '0.00';
|
||||
} else {
|
||||
$price = isset($raw['price']) ? (float) $raw['price'] : (float) ($raw['pay_price'] ?? $data['pay_price'] ?? 0);
|
||||
$price = round($price, 2);
|
||||
if ($price < 0) {
|
||||
$price_minor = $this->parse_front_cash_minor($raw['price'] ?? $raw['pay_price'] ?? $data['pay_price'] ?? null);
|
||||
$min_minor = $this->parse_front_cash_minor($settings['pay_price_min'] ?? '0') ?? 0;
|
||||
$max_minor = $this->parse_front_cash_minor($settings['pay_price_max'] ?? '0') ?? 0;
|
||||
if ($price_minor === null) {
|
||||
return ['success' => false, 'message' => __('请设置正确的价格', 'aksrt')];
|
||||
}
|
||||
if (!$allow_free && $price <= 0) {
|
||||
if (!$allow_free && $price_minor <= 0) {
|
||||
return ['success' => false, 'message' => __('请设置付费内容的价格', 'aksrt')];
|
||||
}
|
||||
if ($min > 0 && $price < $min) {
|
||||
return ['success' => false, 'message' => sprintf(__('价格不能低于:%s', 'aksrt'), (string) $min)];
|
||||
if ($min_minor > 0 && $price_minor < $min_minor) {
|
||||
return ['success' => false, 'message' => sprintf(__('价格不能低于:%s', 'aksrt'), \AKSRT\Ledger\AssetAmount::decimal_from_minor($min_minor))];
|
||||
}
|
||||
if ($max > 0 && $price > $max) {
|
||||
return ['success' => false, 'message' => sprintf(__('价格不能高于:%s', 'aksrt'), (string) $max)];
|
||||
if ($max_minor > 0 && $price_minor > $max_minor) {
|
||||
return ['success' => false, 'message' => sprintf(__('价格不能高于:%s', 'aksrt'), \AKSRT\Ledger\AssetAmount::decimal_from_minor($max_minor))];
|
||||
}
|
||||
|
||||
if ($vip_manual) {
|
||||
$vip1 = isset($raw['vip1_price']) ? (float) $raw['vip1_price'] : (float) ($raw['vip_1_price'] ?? 0);
|
||||
$vip2 = isset($raw['vip2_price']) ? (float) $raw['vip2_price'] : (float) ($raw['vip_2_price'] ?? 0);
|
||||
$vip1 = round($vip1, 2);
|
||||
$vip2 = round($vip2, 2);
|
||||
if ($vip1 > $price) {
|
||||
$vip1_minor = $this->parse_front_cash_minor($raw['vip1_price'] ?? $raw['vip_1_price'] ?? null);
|
||||
$vip2_minor = $this->parse_front_cash_minor($raw['vip2_price'] ?? $raw['vip_2_price'] ?? null);
|
||||
if ($vip1_minor === null || $vip2_minor === null) {
|
||||
return ['success' => false, 'message' => __('请设置正确的会员价格', 'aksrt')];
|
||||
}
|
||||
if ($vip1_minor > $price_minor) {
|
||||
return ['success' => false, 'message' => __('会员价不能高于普通价', 'aksrt')];
|
||||
}
|
||||
if ($vip2 > $vip1) {
|
||||
if ($vip2_minor > $vip1_minor) {
|
||||
return ['success' => false, 'message' => __('更高等级会员价不能高于较低等级会员价', 'aksrt')];
|
||||
}
|
||||
} else {
|
||||
$vip1 = round($price * $vip1_disc / 100, 2);
|
||||
$vip2 = round($price * $vip2_disc / 100, 2);
|
||||
$vip1_minor = \AKSRT\Ledger\AssetAmount::percentage($price_minor, max(0, min(100, $vip1_disc)));
|
||||
$vip2_minor = \AKSRT\Ledger\AssetAmount::percentage($price_minor, max(0, min(100, $vip2_disc)));
|
||||
}
|
||||
$vip1 = max(0, min($price, $vip1));
|
||||
$vip2 = max(0, min($vip1, $vip2));
|
||||
$vip1_minor = max(0, min($price_minor, $vip1_minor));
|
||||
$vip2_minor = max(0, min($vip1_minor, $vip2_minor));
|
||||
|
||||
$meta['price'] = $price;
|
||||
$meta['price'] = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
|
||||
$meta['points_price'] = 0;
|
||||
$meta['vip1_price'] = $vip1;
|
||||
$meta['vip2_price'] = $vip2;
|
||||
$meta['vip1_price'] = \AKSRT\Ledger\AssetAmount::decimal_from_minor($vip1_minor);
|
||||
$meta['vip2_price'] = \AKSRT\Ledger\AssetAmount::decimal_from_minor($vip2_minor);
|
||||
$meta['vip1_points'] = 0;
|
||||
$meta['vip2_points'] = 0;
|
||||
}
|
||||
@@ -159,6 +164,40 @@ trait FrontSubmitPay {
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
private function parse_front_points($value): ?int {
|
||||
if (is_int($value)) {
|
||||
return $value >= 0 ? $value : null;
|
||||
}
|
||||
if (!is_string($value) || !preg_match('/^(0|[1-9]\d*)$/', trim($value))) {
|
||||
return null;
|
||||
}
|
||||
$value = trim($value);
|
||||
if (strlen($value) > strlen((string) PHP_INT_MAX)
|
||||
|| (strlen($value) === strlen((string) PHP_INT_MAX) && strcmp($value, (string) PHP_INT_MAX) > 0)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
private function parse_front_cash_minor($value): ?int {
|
||||
if (!is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance_minor($value);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function parse_front_points_limit($value): int {
|
||||
if (!is_string($value) || !preg_match('/^(0|[1-9]\d*)(?:\.00)?$/', trim($value), $matches)) {
|
||||
return 0;
|
||||
}
|
||||
return $this->parse_front_points((string) $matches[1]) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $raw
|
||||
* @return array<int, array<string, string>>
|
||||
@@ -210,7 +249,13 @@ trait FrontSubmitPay {
|
||||
$meta = DomainModel::sanitize_pay_meta($meta);
|
||||
update_post_meta($post_id, DomainModel::PAY_META_KEY, $meta);
|
||||
update_post_meta($post_id, DomainModel::PAY_TYPE_KEY, (string) ($meta['pay_type'] ?? DomainModel::PAY_TYPE_NO));
|
||||
update_post_meta($post_id, DomainModel::PAY_PRICE_KEY, (float) ($meta['price'] ?? 0));
|
||||
update_post_meta(
|
||||
$post_id,
|
||||
DomainModel::PAY_PRICE_KEY,
|
||||
($meta['pay_modo'] ?? '0') === 'points'
|
||||
? (int) ($meta['points_price'] ?? 0)
|
||||
: (string) ($meta['price'] ?? '0.00')
|
||||
);
|
||||
update_post_meta(
|
||||
$post_id,
|
||||
DomainModel::RESOURCE_MARKER_KEY,
|
||||
|
||||
+23
-10
@@ -473,13 +473,13 @@ class PostMetaBox {
|
||||
$type_label = $labels[$pay_type] ?? $pay_type;
|
||||
$modo = (($pay['pay_modo'] ?? '0') === 'points') ? 'points' : '0';
|
||||
if ($modo === 'points') {
|
||||
$price_text = sprintf(__('%d 积分', 'aksrt'), (int) ceil((float) ($pay['points_price'] ?? $pay['price'] ?? 0)));
|
||||
$price_text = sprintf(__('%d 积分', 'aksrt'), max(0, (int) ($pay['points_price'] ?? 0)));
|
||||
} else {
|
||||
$price_text = '¥' . number_format((float) ($pay['price'] ?? 0), 2);
|
||||
$price_text = '¥' . (string) ($pay['price'] ?? '0.00');
|
||||
}
|
||||
$meta_sales = (int) get_post_meta($post_id, DomainModel::RESOURCE_SALES_VOLUME_KEY, true);
|
||||
$db_sales = 0;
|
||||
$revenue = 0.0;
|
||||
$revenue = '0.00';
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$map = \AKSRT\Payment\OrderRepository::get_instance()->get_content_sales_map(
|
||||
[$post_id],
|
||||
@@ -487,7 +487,7 @@ class PostMetaBox {
|
||||
);
|
||||
if (isset($map[$post_id])) {
|
||||
$db_sales = (int) ($map[$post_id]['sales_count'] ?? 0);
|
||||
$revenue = (float) ($map[$post_id]['revenue'] ?? 0);
|
||||
$revenue = (string) ($map[$post_id]['revenue'] ?? '0.00');
|
||||
}
|
||||
}
|
||||
$orders_url = admin_url('admin.php?page=aksrt_orders&product_type=resource&product_id=' . $post_id);
|
||||
@@ -502,7 +502,7 @@ class PostMetaBox {
|
||||
'<br><a href="%s" title="%s">%s</a>',
|
||||
esc_url($orders_url),
|
||||
esc_attr__('查看销售明细', 'aksrt'),
|
||||
esc_html(sprintf(__('真实销量 %d · ¥%s', 'aksrt'), $db_sales, number_format($revenue, 2)))
|
||||
esc_html(sprintf(__('真实销量 %d · ¥%s', 'aksrt'), $db_sales, $revenue))
|
||||
);
|
||||
} else {
|
||||
echo '<br><span class="description">' . esc_html__('暂无订单', 'aksrt') . '</span>';
|
||||
@@ -682,11 +682,18 @@ class PostMetaBox {
|
||||
$modo = sanitize_key($modo_raw);
|
||||
$meta['pay_modo'] = ($modo === 'points') ? 'points' : '0';
|
||||
}
|
||||
if ($price_raw !== '' && is_numeric($price_raw)) {
|
||||
$meta['price'] = max(0, (float) $price_raw);
|
||||
if ($price_raw !== '') {
|
||||
try {
|
||||
$meta['price'] = \AKSRT\Ledger\AssetAmount::balance($price_raw)->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ($points_raw !== '' && is_numeric($points_raw)) {
|
||||
$meta['points_price'] = max(0, (int) $points_raw);
|
||||
if ($points_raw !== '') {
|
||||
if (!preg_match('/^(0|[1-9]\d*)$/', $points_raw)) {
|
||||
return;
|
||||
}
|
||||
$meta['points_price'] = (int) $points_raw;
|
||||
}
|
||||
if ($virtual_raw !== '' && is_numeric($virtual_raw)) {
|
||||
$meta['virtual_sales'] = max(0, (int) $virtual_raw);
|
||||
@@ -695,7 +702,13 @@ class PostMetaBox {
|
||||
$meta = DomainModel::sanitize_pay_meta($meta);
|
||||
update_post_meta($post_id, DomainModel::PAY_META_KEY, $meta);
|
||||
update_post_meta($post_id, DomainModel::PAY_TYPE_KEY, (string) ($meta['pay_type'] ?? DomainModel::PAY_TYPE_NO));
|
||||
update_post_meta($post_id, DomainModel::PAY_PRICE_KEY, (float) ($meta['price'] ?? 0));
|
||||
update_post_meta(
|
||||
$post_id,
|
||||
DomainModel::PAY_PRICE_KEY,
|
||||
($meta['pay_modo'] ?? '0') === 'points'
|
||||
? (int) ($meta['points_price'] ?? 0)
|
||||
: (string) ($meta['price'] ?? '0.00')
|
||||
);
|
||||
if (class_exists('AKSRT\\Core\\DomainModel') && method_exists('AKSRT\\Core\\DomainModel', 'is_resource_pay_type')) {
|
||||
update_post_meta(
|
||||
$post_id,
|
||||
|
||||
@@ -456,18 +456,11 @@ class ResourceMetaBox {
|
||||
if ($pay_modo !== 'points') {
|
||||
$pay_modo = '0';
|
||||
}
|
||||
$price = max(0, (float) ($_POST['aksrt_pay_price'] ?? 0));
|
||||
$price = trim((string) wp_unslash($_POST['aksrt_pay_price'] ?? '0'));
|
||||
$points_price = max(0, (int) ($_POST['aksrt_pay_points_price'] ?? 0));
|
||||
// Keep legacy single-price editors usable: if points field empty and modo points, mirror price.
|
||||
if ($pay_modo === 'points' && $points_price <= 0 && $price > 0) {
|
||||
$points_price = (int) ceil($price);
|
||||
}
|
||||
if ($pay_modo !== 'points' && $price <= 0 && $points_price > 0) {
|
||||
$price = (float) $points_price;
|
||||
}
|
||||
$original_price = max(0, (float) ($_POST['aksrt_pay_original_price'] ?? 0));
|
||||
$vip1_price = max(0, (float) ($_POST['aksrt_pay_vip1_price'] ?? 0));
|
||||
$vip2_price = max(0, (float) ($_POST['aksrt_pay_vip2_price'] ?? 0));
|
||||
$original_price = trim((string) wp_unslash($_POST['aksrt_pay_original_price'] ?? '0'));
|
||||
$vip1_price = trim((string) wp_unslash($_POST['aksrt_pay_vip1_price'] ?? '0'));
|
||||
$vip2_price = trim((string) wp_unslash($_POST['aksrt_pay_vip2_price'] ?? '0'));
|
||||
$vip1_points = max(0, (int) ($_POST['aksrt_pay_vip1_points'] ?? 0));
|
||||
$vip2_points = max(0, (int) ($_POST['aksrt_pay_vip2_points'] ?? 0));
|
||||
$pay_limit = (int) ($_POST['aksrt_pay_limit'] ?? 0);
|
||||
@@ -527,10 +520,10 @@ class ResourceMetaBox {
|
||||
'card_keys' => isset($_POST['aksrt_pay_card_keys']) ? array_filter(array_map('trim', explode("\n", (string) $_POST['aksrt_pay_card_keys']))) : [],
|
||||
'free_preview' => $_POST['aksrt_pay_free_preview'] ?? '',
|
||||
'show_sales' => !empty($_POST['aksrt_pay_show_sales']),
|
||||
'download_limit_over_price' => max(0, (float) ($_POST['aksrt_pay_limit_over_price'] ?? 0)),
|
||||
'download_limit_over_price' => trim((string) wp_unslash($_POST['aksrt_pay_limit_over_price'] ?? '0')),
|
||||
'coupon_s' => !empty($_POST['aksrt_pay_coupon_s']),
|
||||
'coupon_desc' => (string) ($_POST['aksrt_pay_coupon_desc'] ?? ''),
|
||||
'pay_rebate_discount' => max(0, (float) ($_POST['aksrt_pay_rebate_discount'] ?? 0)),
|
||||
'pay_rebate_discount' => trim((string) wp_unslash($_POST['aksrt_pay_rebate_discount'] ?? '0')),
|
||||
]);
|
||||
|
||||
update_post_meta($post_id, DomainModel::PAY_META_KEY, $pay_meta);
|
||||
@@ -538,8 +531,8 @@ class ResourceMetaBox {
|
||||
// 同步标量字段用于查询(模仿 legacy 的 aksrtpay_type 同步)
|
||||
update_post_meta($post_id, DomainModel::PAY_TYPE_KEY, (string) $pay_meta['pay_type']);
|
||||
$query_price = ($pay_meta['pay_modo'] === 'points')
|
||||
? (float) $pay_meta['points_price']
|
||||
: (float) $pay_meta['price'];
|
||||
? (int) $pay_meta['points_price']
|
||||
: (string) $pay_meta['price'];
|
||||
update_post_meta($post_id, DomainModel::PAY_PRICE_KEY, $query_price);
|
||||
update_post_meta($post_id, '_aksrt_pay_modo', (string) $pay_meta['pay_modo']);
|
||||
update_post_meta($post_id, '_aksrt_pay_limit', (int) $pay_meta['pay_limit']);
|
||||
|
||||
@@ -143,8 +143,7 @@ trait ScriptsPart1b {
|
||||
&& class_exists('\AKSRT\Settings\Settings')
|
||||
&& \AKSRT\Settings\Settings::credits_enabled();
|
||||
$balance_on = is_user_logged_in()
|
||||
&& class_exists('\AKSRT\Credits\Balance')
|
||||
&& \AKSRT\Credits\Balance::enabled();
|
||||
&& !empty(\AKSRT\Settings\Settings::get_payment_recharge_settings()['enabled']);
|
||||
$affiliate_on = is_user_logged_in()
|
||||
&& class_exists('\\AKSRT\\Settings\\Settings')
|
||||
&& !empty(\AKSRT\Settings\Settings::get_affiliate_settings()['enabled']);
|
||||
@@ -656,7 +655,7 @@ trait ScriptsPart1b {
|
||||
wp_enqueue_script(
|
||||
'aksrt-cms-download',
|
||||
$base_uri . '/assets/js/cms-download.js',
|
||||
['loader_js', 'aksrt-runtime'],
|
||||
['loader_js', 'aksrt-runtime', 'aksrt-core-request'],
|
||||
file_exists($cms_download_js_path) ? (string) filemtime($cms_download_js_path) : $version,
|
||||
true
|
||||
);
|
||||
@@ -709,7 +708,7 @@ trait ScriptsPart1b {
|
||||
wp_enqueue_script(
|
||||
'aksrt-credits',
|
||||
$base_uri . '/assets/js/credits.js',
|
||||
['loader_js', 'aksrt-lazy-image-attr', 'aksrt-core-modal-runtime', 'aksrt-vip-coordinator', 'aksrt-notyf'],
|
||||
['loader_js', 'aksrt-lazy-image-attr', 'aksrt-core-request', 'aksrt-core-modal-runtime', 'aksrt-vip-coordinator', 'aksrt-notyf'],
|
||||
file_exists($credits_js_path) ? (string) filemtime($credits_js_path) : $version,
|
||||
true
|
||||
);
|
||||
|
||||
@@ -334,7 +334,7 @@ trait ScriptsPart1c {
|
||||
wp_enqueue_script(
|
||||
'aksrt-reward',
|
||||
$base_uri . '/assets/js/reward.js',
|
||||
['loader_js'],
|
||||
['loader_js', 'aksrt-core-request'],
|
||||
(string) filemtime($reward_js),
|
||||
true
|
||||
);
|
||||
|
||||
+18
-8
@@ -161,11 +161,11 @@ class Setup {
|
||||
'enum' => array_keys(DomainModel::pay_type_labels()),
|
||||
],
|
||||
'pay_modo' => ['type' => 'string', 'enum' => ['0', 'points']],
|
||||
'price' => ['type' => 'number'],
|
||||
'price' => ['type' => 'string', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
'points_price' => ['type' => 'integer'],
|
||||
'original_price' => ['type' => 'number'],
|
||||
'vip1_price' => ['type' => 'number'],
|
||||
'vip2_price' => ['type' => 'number'],
|
||||
'original_price' => ['type' => 'string', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
'vip1_price' => ['type' => 'string', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
'vip2_price' => ['type' => 'string', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
'vip1_points' => ['type' => 'integer'],
|
||||
'vip2_points' => ['type' => 'integer'],
|
||||
'pay_limit' => ['type' => 'integer', 'enum' => [0, 1, 2]],
|
||||
@@ -221,13 +221,23 @@ class Setup {
|
||||
]);
|
||||
|
||||
register_post_meta('post', DomainModel::PAY_PRICE_KEY, [
|
||||
'type' => 'number',
|
||||
'type' => 'string',
|
||||
'description' => 'Query-friendly paid content price.',
|
||||
'single' => true,
|
||||
'default' => 0,
|
||||
'default' => '0.00',
|
||||
'show_in_rest' => true,
|
||||
'sanitize_callback' => static function ($value) {
|
||||
return max(0, (float) $value);
|
||||
'sanitize_callback' => static function ($value): string {
|
||||
if (is_int($value)) {
|
||||
return (string) max(0, $value);
|
||||
}
|
||||
if (!is_string($value)) {
|
||||
return '0.00';
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return '0.00';
|
||||
}
|
||||
},
|
||||
'auth_callback' => $post_edit_auth,
|
||||
]);
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Credits;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class Balance {
|
||||
|
||||
public const META_KEY = 'aksrt_balance';
|
||||
|
||||
/**
|
||||
* Current cash balance (yuan).
|
||||
*/
|
||||
public static function balance(int $user_id): float {
|
||||
if ($user_id <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
$val = get_user_meta($user_id, self::META_KEY, true);
|
||||
return is_numeric($val) ? round((float) $val, 2) : 0.0;
|
||||
}
|
||||
|
||||
public static function get_balance(int $user_id): float {
|
||||
return self::balance($user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Credit cash wallet.
|
||||
*
|
||||
* @param array<string,mixed> $ref
|
||||
* @return float|\WP_Error New balance.
|
||||
*/
|
||||
public static function add(int $user_id, float $delta, string $reason, array $ref = [], string $remark = '', bool $dispatch_hooks = true) {
|
||||
if ($user_id <= 0) {
|
||||
return new \WP_Error('aksrt_balance_user', __('用户无效', 'aksrt'));
|
||||
}
|
||||
$delta = round((float) $delta, 2);
|
||||
if ($delta <= 0) {
|
||||
return new \WP_Error('aksrt_balance_delta', __('增加余额必须为正数', 'aksrt'));
|
||||
}
|
||||
|
||||
$new = self::atomic_update($user_id, $delta, false);
|
||||
if ($new === null) {
|
||||
return new \WP_Error('aksrt_balance_db', __('余额写入失败', 'aksrt'));
|
||||
}
|
||||
self::log($user_id, $delta, $new, $reason, $ref, $remark);
|
||||
if ($dispatch_hooks) {
|
||||
do_action('aksrt_balance_added', $user_id, $delta, $reason, $new);
|
||||
}
|
||||
return $new;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debit cash wallet.
|
||||
*
|
||||
* @param array<string,mixed> $ref
|
||||
* @return float|\WP_Error New balance.
|
||||
*/
|
||||
public static function deduct(int $user_id, float $cost, string $reason, array $ref = [], string $remark = '') {
|
||||
if ($user_id <= 0) {
|
||||
return new \WP_Error('aksrt_balance_user', __('用户无效', 'aksrt'));
|
||||
}
|
||||
$cost = round((float) $cost, 2);
|
||||
if ($cost < 0) {
|
||||
return new \WP_Error('aksrt_balance_delta', __('扣减余额必须为非负数', 'aksrt'));
|
||||
}
|
||||
if ($cost === 0.0) {
|
||||
return self::balance($user_id);
|
||||
}
|
||||
|
||||
$new = self::atomic_update($user_id, $cost, true);
|
||||
if ($new === null) {
|
||||
$current = self::balance($user_id);
|
||||
return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), [
|
||||
'balance' => $current,
|
||||
'cost' => $cost,
|
||||
]);
|
||||
}
|
||||
self::log($user_id, -$cost, $new, $reason, $ref, $remark);
|
||||
do_action('aksrt_balance_deducted', $user_id, $cost, $reason, $new);
|
||||
return $new;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return float|null
|
||||
*/
|
||||
private static function atomic_update(int $user_id, float $abs_delta, bool $is_deduct) {
|
||||
global $wpdb;
|
||||
|
||||
$abs_delta = round(abs($abs_delta), 2);
|
||||
$meta_key = self::META_KEY;
|
||||
$table = $wpdb->usermeta;
|
||||
|
||||
// Ensure meta row exists.
|
||||
$exists = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT umeta_id FROM {$table} WHERE user_id = %d AND meta_key = %s LIMIT 1",
|
||||
$user_id,
|
||||
$meta_key
|
||||
));
|
||||
if (!$exists) {
|
||||
add_user_meta($user_id, $meta_key, '0', true);
|
||||
}
|
||||
|
||||
if ($is_deduct) {
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$updated = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table}
|
||||
SET meta_value = CAST(ROUND(CAST(meta_value AS DECIMAL(18,2)) - %f, 2) AS CHAR)
|
||||
WHERE user_id = %d AND meta_key = %s
|
||||
AND CAST(meta_value AS DECIMAL(18,2)) >= %f",
|
||||
$abs_delta,
|
||||
$user_id,
|
||||
$meta_key,
|
||||
$abs_delta
|
||||
));
|
||||
} else {
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$updated = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table}
|
||||
SET meta_value = CAST(ROUND(CAST(meta_value AS DECIMAL(18,2)) + %f, 2) AS CHAR)
|
||||
WHERE user_id = %d AND meta_key = %s",
|
||||
$abs_delta,
|
||||
$user_id,
|
||||
$meta_key
|
||||
));
|
||||
}
|
||||
|
||||
if ($updated !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
wp_cache_delete($user_id, 'user_meta');
|
||||
return self::balance($user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $ref
|
||||
*/
|
||||
public static function log(int $user_id, float $delta, float $balance_after, string $reason, array $ref = [], string $remark = ''): void {
|
||||
global $wpdb;
|
||||
if (!class_exists('\AKSRT\Core\DBMigrator')) {
|
||||
return;
|
||||
}
|
||||
$table = \AKSRT\Core\DBMigrator::table('balance_log');
|
||||
if ($table === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$ip = '';
|
||||
if (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||
$ip = sanitize_text_field(wp_unslash((string) $_SERVER['REMOTE_ADDR']));
|
||||
}
|
||||
$ua = '';
|
||||
if (!empty($_SERVER['HTTP_USER_AGENT'])) {
|
||||
$ua = substr(sanitize_text_field(wp_unslash((string) $_SERVER['HTTP_USER_AGENT'])), 0, 255);
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
[
|
||||
'user_id' => $user_id,
|
||||
'delta' => round($delta, 2),
|
||||
'balance_after' => round($balance_after, 2),
|
||||
'reason' => sanitize_key(substr($reason, 0, 64)),
|
||||
'remark' => $remark !== '' ? sanitize_text_field(substr($remark, 0, 255)) : null,
|
||||
'ref_type' => isset($ref['type']) ? sanitize_key((string) $ref['type']) : null,
|
||||
'ref_id' => isset($ref['id']) ? (int) $ref['id'] : (isset($ref['ref_id']) ? (int) $ref['ref_id'] : null),
|
||||
'ip' => $ip !== '' ? $ip : null,
|
||||
'ua' => $ua !== '' ? $ua : null,
|
||||
'created_at' => current_time('mysql', true),
|
||||
],
|
||||
['%d', '%f', '%f', '%s', '%s', '%s', '%d', '%s', '%s', '%s']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $args
|
||||
* @return array{items:array<int,array<string,mixed>>,total:int}
|
||||
*/
|
||||
public static function get_log(array $args = []): array {
|
||||
global $wpdb;
|
||||
if (!class_exists('\AKSRT\Core\DBMigrator')) {
|
||||
return ['items' => [], 'total' => 0];
|
||||
}
|
||||
$table = \AKSRT\Core\DBMigrator::table('balance_log');
|
||||
if ($table === '') {
|
||||
return ['items' => [], 'total' => 0];
|
||||
}
|
||||
|
||||
$user_id = max(0, (int) ($args['user_id'] ?? 0));
|
||||
$page = max(1, (int) ($args['page'] ?? 1));
|
||||
$per_page = max(1, min(50, (int) ($args['per_page'] ?? 10)));
|
||||
$offset = ($page - 1) * $per_page;
|
||||
|
||||
$where = '1=1';
|
||||
$params = [];
|
||||
if ($user_id > 0) {
|
||||
$where .= ' AND user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$total_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}";
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($total_sql, $params)) : $wpdb->get_var($total_sql));
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$list_sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d";
|
||||
$list_params = array_merge($params, [$per_page, $offset]);
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
$rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A);
|
||||
if (!is_array($rows)) {
|
||||
$rows = [];
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ($rows as $row) {
|
||||
$items[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'user_id' => (int) ($row['user_id'] ?? 0),
|
||||
'delta' => round((float) ($row['delta'] ?? 0), 2),
|
||||
'balance_after' => round((float) ($row['balance_after'] ?? 0), 2),
|
||||
'reason' => (string) ($row['reason'] ?? ''),
|
||||
'remark' => (string) ($row['remark'] ?? ''),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return ['items' => $items, 'total' => $total];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether cash wallet feature is enabled (legacy pay_balance_s).
|
||||
*/
|
||||
public static function enabled(): bool {
|
||||
if (!class_exists('\AKSRT\Settings\Settings')) {
|
||||
return false;
|
||||
}
|
||||
if (method_exists('\AKSRT\Settings\Settings', 'get_payment_recharge_settings')) {
|
||||
$cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings();
|
||||
return !empty($cfg['enabled']);
|
||||
}
|
||||
return !empty(\AKSRT\Settings\Settings::get_all()['pay_balance_s']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically transfer cash balance between two users.
|
||||
*
|
||||
* @param array<string,mixed> $ref
|
||||
* @return array{sender_balance:float,recipient_balance:float}|\WP_Error
|
||||
*/
|
||||
public static function transfer(int $sender_id, int $recipient_id, float $amount, string $reason_out = 'tip_sent', string $reason_in = 'tip_received', array $ref = [], string $remark = '') {
|
||||
global $wpdb;
|
||||
|
||||
$amount = round($amount, 2);
|
||||
if ($sender_id <= 0 || $recipient_id <= 0 || $sender_id === $recipient_id) {
|
||||
return new \WP_Error('aksrt_balance_transfer_user', __('转账用户无效', 'aksrt'));
|
||||
}
|
||||
if ($amount <= 0) {
|
||||
return new \WP_Error('aksrt_balance_transfer_amount', __('转账金额必须大于零', 'aksrt'));
|
||||
}
|
||||
if (!class_exists('\AKSRT\Core\DBMigrator')) {
|
||||
return new \WP_Error('aksrt_balance_transfer_unavailable', __('余额服务不可用', 'aksrt'));
|
||||
}
|
||||
|
||||
foreach ([$sender_id, $recipient_id] as $user_id) {
|
||||
if (!metadata_exists('user', $user_id, self::META_KEY)) {
|
||||
add_user_meta($user_id, self::META_KEY, '0', true);
|
||||
}
|
||||
}
|
||||
|
||||
$meta_table = $wpdb->usermeta;
|
||||
$log_table = \AKSRT\Core\DBMigrator::table('balance_log');
|
||||
$locked_ids = [$sender_id, $recipient_id];
|
||||
sort($locked_ids, SORT_NUMERIC);
|
||||
|
||||
$wpdb->query('START TRANSACTION');
|
||||
try {
|
||||
foreach ($locked_ids as $user_id) {
|
||||
$wpdb->get_var($wpdb->prepare(
|
||||
"SELECT umeta_id FROM {$meta_table} WHERE user_id = %d AND meta_key = %s FOR UPDATE",
|
||||
$user_id,
|
||||
self::META_KEY
|
||||
));
|
||||
}
|
||||
|
||||
$sender_before = (float) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT meta_value FROM {$meta_table} WHERE user_id = %d AND meta_key = %s LIMIT 1",
|
||||
$sender_id,
|
||||
self::META_KEY
|
||||
));
|
||||
if ($sender_before < $amount) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'));
|
||||
}
|
||||
|
||||
$sender_after = round($sender_before - $amount, 2);
|
||||
$recipient_before = (float) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT meta_value FROM {$meta_table} WHERE user_id = %d AND meta_key = %s LIMIT 1",
|
||||
$recipient_id,
|
||||
self::META_KEY
|
||||
));
|
||||
$recipient_after = round($recipient_before + $amount, 2);
|
||||
|
||||
$sender_updated = $wpdb->update($meta_table, ['meta_value' => (string) $sender_after], ['user_id' => $sender_id, 'meta_key' => self::META_KEY], ['%s'], ['%d', '%s']);
|
||||
$recipient_updated = $wpdb->update($meta_table, ['meta_value' => (string) $recipient_after], ['user_id' => $recipient_id, 'meta_key' => self::META_KEY], ['%s'], ['%d', '%s']);
|
||||
if ($sender_updated !== 1 || $recipient_updated !== 1) {
|
||||
throw new \RuntimeException('balance_update_failed');
|
||||
}
|
||||
|
||||
$ref_type = isset($ref['type']) ? sanitize_key((string) $ref['type']) : 'tip';
|
||||
$ref_id = isset($ref['id']) ? (int) $ref['id'] : null;
|
||||
$created_at = current_time('mysql', true);
|
||||
$log_rows = [
|
||||
[$sender_id, -$amount, $sender_after, $reason_out],
|
||||
[$recipient_id, $amount, $recipient_after, $reason_in],
|
||||
];
|
||||
foreach ($log_rows as [$user_id, $delta, $balance_after, $reason]) {
|
||||
$inserted = $wpdb->insert($log_table, [
|
||||
'user_id' => $user_id,
|
||||
'delta' => $delta,
|
||||
'balance_after' => $balance_after,
|
||||
'reason' => sanitize_key($reason),
|
||||
'remark' => $remark !== '' ? sanitize_text_field(substr($remark, 0, 255)) : null,
|
||||
'ref_type' => $ref_type,
|
||||
'ref_id' => $ref_id,
|
||||
'created_at' => $created_at,
|
||||
], ['%d', '%f', '%f', '%s', '%s', '%s', '%d', '%s']);
|
||||
if ($inserted !== 1) {
|
||||
throw new \RuntimeException('balance_log_failed');
|
||||
}
|
||||
}
|
||||
|
||||
$wpdb->query('COMMIT');
|
||||
wp_cache_delete($sender_id, 'user_meta');
|
||||
wp_cache_delete($recipient_id, 'user_meta');
|
||||
do_action('aksrt_balance_transferred', $sender_id, $recipient_id, $amount, $ref);
|
||||
return ['sender_balance' => $sender_after, 'recipient_balance' => $recipient_after];
|
||||
} catch (\Throwable $e) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return new \WP_Error('aksrt_balance_transfer_failed', __('打赏入账失败,请稍后重试', 'aksrt'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format yuan for display.
|
||||
*/
|
||||
public static function format(float $amount): string {
|
||||
return number_format_i18n(round($amount, 2), 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reason label for ledger UI.
|
||||
*/
|
||||
public static function reason_label(string $reason): string {
|
||||
$map = [
|
||||
'card_pass_balance' => __('卡密充值', 'aksrt'),
|
||||
'order_payment' => __('订单支付', 'aksrt'),
|
||||
'order_refund' => __('订单退款', 'aksrt'),
|
||||
'recharge' => __('余额充值', 'aksrt'),
|
||||
'transfer_in' => __('转入', 'aksrt'),
|
||||
'transfer_out' => __('转出', 'aksrt'),
|
||||
'tip_sent' => __('打赏支出', 'aksrt'),
|
||||
'tip_received' => __('收到打赏', 'aksrt'),
|
||||
'admin' => __('管理员调整', 'aksrt'),
|
||||
'points_exchange_out' => __('余额兑换积分', 'aksrt'),
|
||||
'points_exchange_refund' => __('余额兑换积分退款', 'aksrt'),
|
||||
'creator_income' => __('创作收益结算', 'aksrt'),
|
||||
'rebate' => __('推广返佣结算', 'aksrt'),
|
||||
'withdraw_hold' => __('提现冻结', 'aksrt'),
|
||||
'withdraw_refund' => __('提现退回', 'aksrt'),
|
||||
];
|
||||
$key = sanitize_key($reason);
|
||||
return $map[$key] ?? ($reason !== '' ? $reason : __('余额变动', 'aksrt'));
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ class BalanceService {
|
||||
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
add_action('aksrt_order_paid', [$this, 'on_order_paid'], 20, 2);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -12,93 +12,96 @@ trait BalanceServiceCharge {
|
||||
|
||||
|
||||
/**
|
||||
* When a recharge order is paid, credit the wallet.
|
||||
* Atomically confirms a recharge order and credits its cash account.
|
||||
*
|
||||
* @param string $order_number Order number.
|
||||
* @param array<string,mixed> $order Order row.
|
||||
* @param array<string,mixed> $order
|
||||
*/
|
||||
public function on_order_paid(string $order_number, array $order = []): void {
|
||||
if ($order === [] && class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number) ?: [];
|
||||
}
|
||||
if (!$order) {
|
||||
return;
|
||||
}
|
||||
$type = sanitize_key((string) ($order['product_type'] ?? ''));
|
||||
public function complete_recharge_payment(array $order, string $payment_method, string $provider_transaction_id, string $status_note = ''): bool {
|
||||
$order_number = sanitize_text_field((string) ($order['order_number'] ?? ''));
|
||||
$user_id = (int) ($order['user_id'] ?? 0);
|
||||
if ($user_id <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
|
||||
return;
|
||||
}
|
||||
if (!class_exists('AKSRT\\Credits\\Balance')) {
|
||||
return;
|
||||
}
|
||||
// Honor pay_auto_to_balance (default on). When off, leave order paid without wallet credit.
|
||||
if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_payment_recharge_settings')) {
|
||||
$recharge_cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings();
|
||||
if (isset($recharge_cfg['auto_to_balance']) && empty($recharge_cfg['auto_to_balance'])) {
|
||||
return;
|
||||
}
|
||||
if ($order_number === '' || $user_id <= 0 || sanitize_key((string) ($order['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
|
||||
return false;
|
||||
}
|
||||
$note = json_decode((string) ($order['note'] ?? ''), true);
|
||||
if (!is_array($note)) {
|
||||
$note = [];
|
||||
$note = is_array($note) ? $note : [];
|
||||
if (!isset($note['credit_amount']) || !is_string($note['credit_amount'])) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($note['balance_credited'])) {
|
||||
return;
|
||||
$amount_raw = $note['credit_amount'];
|
||||
try {
|
||||
$credit = \AKSRT\Ledger\AssetAmount::balance($amount_raw);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return false;
|
||||
}
|
||||
$credit_amount = isset($note['credit_amount'])
|
||||
? round((float) $note['credit_amount'], 2)
|
||||
: round((float) ($order['amount'] ?? 0), 2);
|
||||
if ($credit_amount <= 0) {
|
||||
return;
|
||||
if ($credit->minor() <= 0) {
|
||||
return false;
|
||||
}
|
||||
global $wpdb;
|
||||
if ($wpdb->query('START TRANSACTION') === false) {
|
||||
return;
|
||||
}
|
||||
$result = Balance::add(
|
||||
$payment_method = sanitize_key($payment_method);
|
||||
$provider_transaction_id = sanitize_text_field($provider_transaction_id);
|
||||
$locked_status = '';
|
||||
$locked_note = [];
|
||||
$guard = function () use ($order_number, $user_id, &$locked_status, &$locked_note) {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('order');
|
||||
$current = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE order_number = %s FOR UPDATE", $order_number), ARRAY_A);
|
||||
if (!is_array($current)
|
||||
|| (int) ($current['user_id'] ?? 0) !== $user_id
|
||||
|| sanitize_key((string) ($current['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|
||||
|| !in_array((string) ($current['status'] ?? ''), [\AKSRT\Payment\Order::STATUS_CREATED, \AKSRT\Payment\Order::STATUS_PENDING], true)
|
||||
) {
|
||||
return new \WP_Error('aksrt_order_confirm_failed', __('订单状态确认失败', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$locked_status = (string) $current['status'];
|
||||
$decoded = json_decode((string) ($current['note'] ?? ''), true);
|
||||
$locked_note = is_array($decoded) ? $decoded : [];
|
||||
return true;
|
||||
};
|
||||
$mutation = function () use ($order_number, $payment_method, $provider_transaction_id, $status_note, $credit, &$locked_status, &$locked_note) {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('order');
|
||||
$locked_note['ledger_credited'] = 1;
|
||||
$locked_note['credited_amount'] = $credit->decimal();
|
||||
if ($status_note !== '') {
|
||||
$locked_note['status_note'] = sanitize_textarea_field($status_note);
|
||||
}
|
||||
$now = current_time('mysql');
|
||||
$updated = $wpdb->update($table, [
|
||||
'status' => \AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
'payment_method' => $payment_method,
|
||||
'payment_transaction_id' => $provider_transaction_id,
|
||||
'paid_at' => $now,
|
||||
'completed_at' => $now,
|
||||
'note' => wp_json_encode($locked_note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
], ['order_number' => $order_number, 'status' => $locked_status], ['%s', '%s', '%s', '%s', '%s', '%s'], ['%s', '%s']);
|
||||
return $updated === 1 ? true : new \WP_Error('aksrt_order_confirm_failed', __('订单状态确认失败', 'aksrt'), ['status' => 409]);
|
||||
};
|
||||
$result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
|
||||
$user_id,
|
||||
$credit_amount,
|
||||
\AKSRT\Ledger\AssetType::BALANCE,
|
||||
$credit->minor(),
|
||||
\AKSRT\Ledger\OperationKey::deterministic('recharge', [$order_number]),
|
||||
'recharge',
|
||||
['type' => 'order', 'order' => $order_number],
|
||||
sprintf(__('余额充值订单 %s', 'aksrt'), $order_number),
|
||||
false
|
||||
$user_id,
|
||||
['payment_method' => $payment_method, 'provider_transaction_id' => $provider_transaction_id],
|
||||
$mutation,
|
||||
$guard
|
||||
);
|
||||
if (is_wp_error($result)) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
$note['balance_credited'] = 1;
|
||||
$note['credited_amount'] = $credit_amount;
|
||||
$updated = $wpdb->update(
|
||||
$wpdb->prefix . 'aksrt_order',
|
||||
['note' => wp_json_encode($note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)],
|
||||
['order_number' => $order_number],
|
||||
['%s'],
|
||||
['%s']
|
||||
);
|
||||
if ($updated === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
wp_cache_delete($user_id, 'user_meta');
|
||||
return;
|
||||
}
|
||||
if ($wpdb->query('COMMIT') === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
wp_cache_delete($user_id, 'user_meta');
|
||||
return;
|
||||
}
|
||||
do_action('aksrt_balance_added', $user_id, $credit_amount, 'recharge', (float) $result);
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
|
||||
$order_number,
|
||||
\AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
['note' => __('充值金额已到账。', 'aksrt')]
|
||||
);
|
||||
if (!$result->replayed) {
|
||||
$paid_order = array_merge($order, [
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PAID,
|
||||
'payment_method' => $payment_method,
|
||||
'payment_transaction_id' => $provider_transaction_id,
|
||||
]);
|
||||
do_action('aksrt_order_status_changed', $order_number, \AKSRT\Payment\Order::STATUS_PAID, $paid_order);
|
||||
do_action('aksrt_order_paid', $order_number, $paid_order);
|
||||
do_action('aksrt_order_status_changed', $order_number, \AKSRT\Payment\Order::STATUS_COMPLETED, array_merge($paid_order, ['status' => \AKSRT\Payment\Order::STATUS_COMPLETED]));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +128,7 @@ trait BalanceServiceCharge {
|
||||
return [
|
||||
'enabled' => !empty($cfg['enabled']),
|
||||
'desc' => (string) ($cfg['desc'] ?? ''),
|
||||
'wallet' => class_exists('AKSRT\\Credits\\Balance') ? Balance::balance($user_id) : 0.0,
|
||||
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'packages' => $packages,
|
||||
'custom_s' => $custom_s,
|
||||
'custom_limit' => $custom_limit,
|
||||
@@ -143,7 +146,7 @@ trait BalanceServiceCharge {
|
||||
|
||||
|
||||
/**
|
||||
* @return array<int,array{key:string,price:float,pay_price:float,title:string,tag:string}>
|
||||
* @return array<int,array{key:string,price:string,pay_price:string,title:string,tag:string}>
|
||||
*/
|
||||
public function parse_packages(string $raw): array {
|
||||
$out = [];
|
||||
@@ -154,23 +157,25 @@ trait BalanceServiceCharge {
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
// pay|credit|title OR credit|pay|title — legacy uses price(credit)|pay_price|tag
|
||||
// Admin help: 支付金额|到账金额|标题
|
||||
$parts = array_map('trim', explode('|', $line));
|
||||
if (count($parts) < 2) {
|
||||
continue;
|
||||
}
|
||||
$pay = (float) $parts[0];
|
||||
$credit = (float) $parts[1];
|
||||
try {
|
||||
$pay = \AKSRT\Ledger\AssetAmount::balance((string) $parts[0]);
|
||||
$credit = \AKSRT\Ledger\AssetAmount::balance((string) $parts[1]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
continue;
|
||||
}
|
||||
$title = $parts[2] ?? '';
|
||||
if ($pay <= 0 || $credit <= 0) {
|
||||
if ($pay->minor() <= 0 || $credit->minor() <= 0) {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'key' => (string) $i,
|
||||
'pay_price' => round($pay, 2),
|
||||
'price' => round($credit, 2),
|
||||
'title' => $title !== '' ? $title : sprintf(__('充值 %s 元', 'aksrt'), (string) $credit),
|
||||
'pay_price' => $pay->decimal(),
|
||||
'price' => $credit->decimal(),
|
||||
'title' => $title !== '' ? $title : sprintf(__('充值 %s 元', 'aksrt'), $credit->decimal()),
|
||||
'tag' => $title,
|
||||
];
|
||||
$i++;
|
||||
@@ -183,7 +188,7 @@ trait BalanceServiceCharge {
|
||||
/**
|
||||
* @return array<string,mixed>|\WP_Error
|
||||
*/
|
||||
public function create_charge_order(int $user_id, string $package_key, float $custom_price, string $payment_method) {
|
||||
public function create_charge_order(int $user_id, string $package_key, string $custom_price, string $payment_method, string $operation_key) {
|
||||
if ($user_id <= 0) {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
@@ -191,23 +196,26 @@ trait BalanceServiceCharge {
|
||||
return new \WP_Error('aksrt_pay_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$view = $this->get_charge_view($user_id);
|
||||
$credit_amount = 0.0;
|
||||
$pay_amount = 0.0;
|
||||
$credit_amount = null;
|
||||
$pay_amount = null;
|
||||
$title = __('余额充值', 'aksrt');
|
||||
|
||||
if ($package_key === 'custom' || $package_key === '') {
|
||||
if (empty($view['custom_s'])) {
|
||||
return new \WP_Error('aksrt_custom_closed', __('未开放自定义金额', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$custom_price = round($custom_price, 2);
|
||||
$min = 1.0;
|
||||
$max = (float) $view['custom_limit'];
|
||||
if ($custom_price < $min || $custom_price > $max) {
|
||||
return new \WP_Error('aksrt_custom_limit', sprintf(__('充值金额需在 %s ~ %s 之间', 'aksrt'), (string) $min, (string) $max), ['status' => 400]);
|
||||
try {
|
||||
$custom_amount = \AKSRT\Ledger\AssetAmount::balance($custom_price);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new \WP_Error('aksrt_invalid_price', __('金额格式无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$pay_amount = $custom_price;
|
||||
$credit_amount = $custom_price;
|
||||
$title = sprintf(__('自定义充值 %s 元', 'aksrt'), (string) $custom_price);
|
||||
$max = max(1, (int) $view['custom_limit']);
|
||||
if ($custom_amount->minor() < 100 || $custom_amount->minor() > $max * 100) {
|
||||
return new \WP_Error('aksrt_custom_limit', sprintf(__('充值金额需在 1 ~ %d 之间', 'aksrt'), $max), ['status' => 400]);
|
||||
}
|
||||
$pay_amount = $custom_amount;
|
||||
$credit_amount = $custom_amount;
|
||||
$title = sprintf(__('自定义充值 %s 元', 'aksrt'), $custom_amount->decimal());
|
||||
$package_key = 'custom';
|
||||
} else {
|
||||
$found = null;
|
||||
@@ -220,14 +228,24 @@ trait BalanceServiceCharge {
|
||||
if (!$found) {
|
||||
return new \WP_Error('aksrt_invalid_package', __('充值套餐无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$pay_amount = (float) $found['pay_price'];
|
||||
$credit_amount = (float) $found['price'];
|
||||
try {
|
||||
$pay_amount = \AKSRT\Ledger\AssetAmount::balance((string) $found['pay_price']);
|
||||
$credit_amount = \AKSRT\Ledger\AssetAmount::balance((string) $found['price']);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new \WP_Error('aksrt_invalid_package', __('充值套餐金额无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$title = (string) $found['title'];
|
||||
}
|
||||
|
||||
if ($pay_amount <= 0 || $credit_amount <= 0) {
|
||||
if (!($pay_amount instanceof \AKSRT\Ledger\AssetAmount)
|
||||
|| !($credit_amount instanceof \AKSRT\Ledger\AssetAmount)
|
||||
|| $pay_amount->minor() <= 0
|
||||
|| $credit_amount->minor() <= 0
|
||||
) {
|
||||
return new \WP_Error('aksrt_invalid_price', __('金额无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$pay_decimal = $pay_amount->decimal();
|
||||
$credit_decimal = $credit_amount->decimal();
|
||||
|
||||
$payment_method = sanitize_key($payment_method);
|
||||
if ($payment_method === '' || $payment_method === 'balance') {
|
||||
@@ -239,22 +257,46 @@ trait BalanceServiceCharge {
|
||||
$note = wp_json_encode([
|
||||
'balance_recharge' => 1,
|
||||
'package' => $package_key,
|
||||
'credit_amount' => $credit_amount,
|
||||
'pay_amount' => $pay_amount,
|
||||
'credit_amount' => $credit_decimal,
|
||||
'pay_amount' => $pay_decimal,
|
||||
'title' => $title,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$existing = $repo->get_by_request_key($operation_key);
|
||||
if (is_array($existing)) {
|
||||
$existing_note = json_decode((string) ($existing['note'] ?? ''), true);
|
||||
$existing_note = is_array($existing_note) ? $existing_note : [];
|
||||
$existing_credit = isset($existing_note['credit_amount']) && is_string($existing_note['credit_amount'])
|
||||
? $existing_note['credit_amount']
|
||||
: '';
|
||||
$existing_pay = isset($existing_note['pay_amount']) && is_string($existing_note['pay_amount'])
|
||||
? $existing_note['pay_amount']
|
||||
: '';
|
||||
$same = (int) ($existing['user_id'] ?? 0) === $user_id
|
||||
&& sanitize_key((string) ($existing['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|
||||
&& sanitize_key((string) ($existing['payment_method'] ?? '')) === $payment_method
|
||||
&& (string) ($existing_note['package'] ?? '') === $package_key
|
||||
&& hash_equals($existing_credit, $credit_decimal)
|
||||
&& hash_equals($existing_pay, $pay_decimal);
|
||||
if (!$same) {
|
||||
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他充值请求', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$order_number = (string) $existing['order_number'];
|
||||
} else {
|
||||
$order_number = $repo->create([
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE,
|
||||
'product_id' => 0,
|
||||
'amount' => $pay_amount,
|
||||
'amount' => $pay_decimal,
|
||||
'currency' => 'CNY',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => $payment_method,
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
|
||||
]);
|
||||
'request_key' => $operation_key,
|
||||
]);
|
||||
}
|
||||
if ($order_number === '') {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
@@ -263,6 +305,28 @@ trait BalanceServiceCharge {
|
||||
if (!$order) {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
if (is_array($existing) && in_array((string) ($order['status'] ?? ''), [
|
||||
\AKSRT\Payment\Order::STATUS_PAID,
|
||||
\AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
], true)) {
|
||||
$ledger_key = \AKSRT\Ledger\OperationKey::deterministic('recharge', [$order_number]);
|
||||
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $ledger_key, 'page' => 1, 'per_page' => 1]);
|
||||
$transaction_id = (int) ($history['items'][0]['id'] ?? 0);
|
||||
if ($transaction_id <= 0) {
|
||||
return new \WP_Error('aksrt_recharge_state', __('充值订单与账本状态不一致', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return [
|
||||
'redirect' => false,
|
||||
'order_number' => $order_number,
|
||||
'amount' => $pay_decimal,
|
||||
'credit_amount' => $credit_decimal,
|
||||
'transaction_id' => $transaction_id,
|
||||
'replayed' => true,
|
||||
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('充值成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
];
|
||||
}
|
||||
$order['subject'] = $title;
|
||||
$order['payment_method'] = $payment_method;
|
||||
|
||||
@@ -285,7 +349,7 @@ trait BalanceServiceCharge {
|
||||
return new \WP_Error(
|
||||
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
|
||||
(string) ($payment_result['message'] ?? __('支付失败', 'aksrt')),
|
||||
['status' => 402, 'order_number' => $order_number]
|
||||
['status' => (int) ($payment_result['status'] ?? 400), 'order_number' => $order_number]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -295,23 +359,29 @@ trait BalanceServiceCharge {
|
||||
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
|
||||
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
|
||||
'order_number' => $order_number,
|
||||
'amount' => $pay_amount,
|
||||
'credit_amount' => $credit_amount,
|
||||
'amount' => $pay_decimal,
|
||||
'credit_amount' => $credit_decimal,
|
||||
'message' => $payment_result['message'],
|
||||
'reload' => false,
|
||||
'poll' => true,
|
||||
'transaction_id' => 0,
|
||||
'replayed' => false,
|
||||
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
];
|
||||
}
|
||||
|
||||
// Synchronous gateway: PaymentOrchestrator's paid transition already fired
|
||||
// aksrt_order_paid, so fulfillment has run through the single hook path.
|
||||
// The synchronous gateway has already committed the recharge and order state.
|
||||
$ledger_key = \AKSRT\Ledger\OperationKey::deterministic('recharge', [$order_number]);
|
||||
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $ledger_key, 'page' => 1, 'per_page' => 1]);
|
||||
|
||||
return [
|
||||
'redirect' => false,
|
||||
'order_number' => $order_number,
|
||||
'amount' => $pay_amount,
|
||||
'credit_amount' => $credit_amount,
|
||||
'wallet' => Balance::balance($user_id),
|
||||
'amount' => $pay_decimal,
|
||||
'credit_amount' => $credit_decimal,
|
||||
'transaction_id' => (int) ($history['items'][0]['id'] ?? 0),
|
||||
'replayed' => false,
|
||||
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('充值成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
];
|
||||
|
||||
+164
-182
@@ -16,16 +16,17 @@ trait BalanceServiceRest {
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'rest_charge_options'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_asset_rest'],
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'rest_charge_create'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_asset_rest'],
|
||||
'args' => [
|
||||
'package' => ['type' => 'string', 'default' => 'custom', 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'custom_price' => ['type' => 'string', 'default' => '0', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
'payment_method' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -33,9 +34,18 @@ trait BalanceServiceRest {
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'rest_transfer'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_asset_rest'],
|
||||
'args' => [
|
||||
'type' => ['type' => 'string', 'required' => true, 'enum' => ['balance', 'points']],
|
||||
'recipient' => ['type' => 'integer', 'required' => true, 'minimum' => 1],
|
||||
'amount' => [
|
||||
'required' => true,
|
||||
'oneOf' => [
|
||||
['type' => 'integer', 'minimum' => 1],
|
||||
['type' => 'string', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -43,9 +53,7 @@ trait BalanceServiceRest {
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'rest_transfer_search'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_asset_rest'],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -53,21 +61,30 @@ trait BalanceServiceRest {
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'rest_exchange_balance_for_points'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_asset_rest'],
|
||||
'args' => ['amount' => ['type' => 'string', 'required' => true, 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$']],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/pay/balance/withdraw', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'rest_balance_withdraw'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function check_asset_rest(\WP_REST_Request $request) {
|
||||
if (!is_user_logged_in()) {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
$nonce = (string) $request->get_header('X-WP-Nonce');
|
||||
if ($nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) {
|
||||
return new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function balance_feature_enabled(): bool {
|
||||
if (!class_exists('\\AKSRT\\Settings\\Settings')) {
|
||||
return false;
|
||||
}
|
||||
$cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings();
|
||||
return !empty($cfg['enabled']);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +93,7 @@ trait BalanceServiceRest {
|
||||
* GET charge options (packages + methods + limits).
|
||||
*/
|
||||
public function rest_charge_options(\WP_REST_Request $request) {
|
||||
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
|
||||
if (!$this->balance_feature_enabled()) {
|
||||
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$user_id = get_current_user_id();
|
||||
@@ -92,19 +109,23 @@ trait BalanceServiceRest {
|
||||
* POST create recharge order and initiate payment.
|
||||
*/
|
||||
public function rest_charge_create(\WP_REST_Request $request) {
|
||||
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
|
||||
if (!$this->balance_feature_enabled()) {
|
||||
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$user_id = get_current_user_id();
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'recharge-create', $user_id);
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $operation_key;
|
||||
}
|
||||
$params = $request->get_json_params();
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$package_key = sanitize_text_field((string) ($params['package'] ?? 'custom'));
|
||||
$custom_price = isset($params['custom_price']) ? (float) $params['custom_price'] : 0.0;
|
||||
$custom_price = isset($params['custom_price']) ? (string) $params['custom_price'] : '0';
|
||||
$payment_method = sanitize_key((string) ($params['payment_method'] ?? 'alipay'));
|
||||
|
||||
$result = $this->create_charge_order($user_id, $package_key, $custom_price, $payment_method);
|
||||
$result = $this->create_charge_order($user_id, $package_key, $custom_price, $payment_method, $operation_key);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
@@ -123,8 +144,12 @@ trait BalanceServiceRest {
|
||||
}
|
||||
$type = sanitize_key((string) ($params['type'] ?? 'balance'));
|
||||
$recipient = (int) ($params['recipient'] ?? 0);
|
||||
$amount_raw = $params['amount'] ?? $params['price'] ?? 0;
|
||||
$amount_raw = $params['amount'] ?? '';
|
||||
$from_id = get_current_user_id();
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'transfer', $from_id);
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $operation_key;
|
||||
}
|
||||
|
||||
if (!in_array($type, ['balance', 'points'], true)) {
|
||||
return new \WP_Error('aksrt_transfer_type', __('转账类型无效', 'aksrt'), ['status' => 400]);
|
||||
@@ -147,45 +172,43 @@ trait BalanceServiceRest {
|
||||
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('balance_transfer', $from_id)) {
|
||||
return new \WP_Error('aksrt_transfer_forbidden', __('当前账号无权转账余额', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$amount = round((float) $amount_raw, 2);
|
||||
if ($amount <= 0) {
|
||||
try {
|
||||
$amount = \AKSRT\Ledger\AssetAmount::balance(is_string($amount_raw) ? $amount_raw : '');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new \WP_Error('aksrt_transfer_amount', __('请输入有效金额', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (!class_exists('AKSRT\\Credits\\Balance')) {
|
||||
return new \WP_Error('aksrt_balance_unavailable', __('余额系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$from_bal = Balance::balance($from_id);
|
||||
if ($from_bal < $amount) {
|
||||
return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]);
|
||||
if ($amount->minor() <= 0) {
|
||||
return new \WP_Error('aksrt_transfer_amount', __('请输入有效金额', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$fee_rate = max(0, min(50, (int) ($cfg['transfer']['service_charge'] ?? 0)));
|
||||
$credit = $fee_rate > 0 ? round($amount * (100 - $fee_rate) / 100, 2) : $amount;
|
||||
$fee_minor = $this->percentage_minor($amount->minor(), $fee_rate);
|
||||
|
||||
$from_user = wp_get_current_user();
|
||||
$to_user = get_userdata($recipient);
|
||||
$deduct = Balance::deduct(
|
||||
$result = \AKSRT\Ledger\LedgerService::get_instance()->transfer(
|
||||
$from_id,
|
||||
$amount,
|
||||
'transfer_out',
|
||||
['type' => 'user', 'id' => $recipient],
|
||||
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient)
|
||||
);
|
||||
if (is_wp_error($deduct)) {
|
||||
return $deduct;
|
||||
}
|
||||
Balance::add(
|
||||
$recipient,
|
||||
$credit,
|
||||
'transfer_in',
|
||||
['type' => 'user', 'id' => $from_id],
|
||||
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id)
|
||||
\AKSRT\Ledger\AssetType::BALANCE,
|
||||
$amount->minor(),
|
||||
$fee_minor,
|
||||
$operation_key,
|
||||
'transfer',
|
||||
['type' => 'user', 'id' => $recipient],
|
||||
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient),
|
||||
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id),
|
||||
['fee_rate' => $fee_rate]
|
||||
);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'message' => __('转账成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
'balance' => Balance::balance($from_id),
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
'balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($from_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -201,57 +224,42 @@ trait BalanceServiceRest {
|
||||
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('points_transfer', $from_id)) {
|
||||
return new \WP_Error('aksrt_transfer_forbidden', __('当前账号无权转账积分', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if (filter_var($amount_raw, FILTER_VALIDATE_INT) === false || (int) $amount_raw <= 0) {
|
||||
if (!is_int($amount_raw) || $amount_raw <= 0) {
|
||||
return new \WP_Error('aksrt_transfer_amount', __('请输入有效积分', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$amount = (int) $amount_raw;
|
||||
$amount = $amount_raw;
|
||||
$min_amount = max(1, (int) ($points_settings['points_transfer_min'] ?? 1));
|
||||
if ($amount < $min_amount) {
|
||||
return new \WP_Error('aksrt_transfer_minimum', sprintf(__('单笔转账至少需要 %d 积分', 'aksrt'), $min_amount), ['status' => 400]);
|
||||
}
|
||||
if (!class_exists('AKSRT\\Credits\\Credits')) {
|
||||
return new \WP_Error('aksrt_credits_unavailable', __('积分系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$fee_rate = max(0, min(50, (int) ($points_settings['points_service_charge'] ?? 0)));
|
||||
$credit = $fee_rate > 0 ? (int) floor($amount * (100 - $fee_rate) / 100) : $amount;
|
||||
$fee = $this->percentage_minor($amount, $fee_rate);
|
||||
$from_user = wp_get_current_user();
|
||||
$to_user = get_userdata($recipient);
|
||||
$deduct = Credits::deduct(
|
||||
$result = \AKSRT\Ledger\LedgerService::get_instance()->transfer(
|
||||
$from_id,
|
||||
$amount,
|
||||
'transfer_out',
|
||||
['type' => 'user', 'id' => $recipient],
|
||||
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient)
|
||||
);
|
||||
if (is_wp_error($deduct)) {
|
||||
return $deduct;
|
||||
}
|
||||
$add = Credits::add(
|
||||
$recipient,
|
||||
$credit,
|
||||
'transfer_in',
|
||||
['type' => 'user', 'id' => $from_id],
|
||||
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id)
|
||||
\AKSRT\Ledger\AssetType::POINTS,
|
||||
$amount,
|
||||
$fee,
|
||||
$operation_key,
|
||||
'transfer',
|
||||
['type' => 'user', 'id' => $recipient],
|
||||
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient),
|
||||
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id),
|
||||
['fee_rate' => $fee_rate]
|
||||
);
|
||||
if (is_wp_error($add)) {
|
||||
$refund = Credits::add(
|
||||
$from_id,
|
||||
$amount,
|
||||
'transfer_refund',
|
||||
['type' => 'user', 'id' => $recipient],
|
||||
__('收款积分入账失败,已退回转账积分', 'aksrt')
|
||||
);
|
||||
if (is_wp_error($refund)) {
|
||||
return new \WP_Error('aksrt_transfer_recovery_failed', __('收款积分入账失败,转账积分退回失败,请联系管理员', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return $add;
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'message' => __('转账成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
'balance' => Credits::balance($from_id),
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
'balance' => $result->balance($from_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -289,11 +297,11 @@ trait BalanceServiceRest {
|
||||
* Exchange integer cash-wallet balance for points.
|
||||
*/
|
||||
public function rest_exchange_balance_for_points(\WP_REST_Request $request) {
|
||||
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
|
||||
if (!$this->balance_feature_enabled()) {
|
||||
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if (!class_exists('AKSRT\\Credits\\Credits') || !class_exists('AKSRT\\Settings\\Settings') || !\AKSRT\Settings\Settings::credits_enabled()) {
|
||||
return new \WP_Error('aksrt_credits_disabled', __('积分功能未启用', 'aksrt'), ['status' => 403]);
|
||||
if (!class_exists('AKSRT\\Settings\\Settings') || !\AKSRT\Settings\Settings::credits_enabled()) {
|
||||
return new \WP_Error('aksrt_points_disabled', __('积分功能未启用', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings();
|
||||
$exchange = is_array($cfg['points_exchange'] ?? null) ? $cfg['points_exchange'] : [];
|
||||
@@ -304,121 +312,95 @@ trait BalanceServiceRest {
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$raw_amount = $params['amount'] ?? 0;
|
||||
if (filter_var($raw_amount, FILTER_VALIDATE_INT) === false || (int) $raw_amount <= 0) {
|
||||
return new \WP_Error('aksrt_exchange_amount', __('兑换余额必须为正整数', 'aksrt'), ['status' => 400]);
|
||||
$raw_amount = $params['amount'] ?? '';
|
||||
try {
|
||||
$amount = \AKSRT\Ledger\AssetAmount::balance(is_string($raw_amount) ? $raw_amount : '');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new \WP_Error('aksrt_exchange_amount', __('兑换余额格式无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($amount->minor() <= 0) {
|
||||
return new \WP_Error('aksrt_exchange_amount', __('兑换余额必须大于零', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$amount = (int) $raw_amount;
|
||||
$rate = max(1, (int) ($exchange['rate'] ?? 100));
|
||||
$daily_max = max(1, (int) ($exchange['daily_max_points'] ?? 100000));
|
||||
if ($amount > intdiv($daily_max, $rate)) {
|
||||
$scaled = $this->multiply_non_negative($amount->minor(), $rate);
|
||||
if (is_wp_error($scaled)) {
|
||||
return $scaled;
|
||||
}
|
||||
if ($scaled % 100 !== 0) {
|
||||
return new \WP_Error('aksrt_exchange_precision', __('该金额无法兑换为整数积分', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$points = intdiv($scaled, 100);
|
||||
if ($points > $daily_max) {
|
||||
return new \WP_Error('aksrt_exchange_daily_limit', sprintf(__('今日购买积分上限为 %d', 'aksrt'), $daily_max), ['status' => 400]);
|
||||
}
|
||||
$points = $amount * $rate;
|
||||
$min_points = max(1, (int) ($exchange['min_points'] ?? 100));
|
||||
if ($points < $min_points) {
|
||||
return new \WP_Error('aksrt_exchange_min', sprintf(__('单笔至少购买 %d 积分', 'aksrt'), $min_points), ['status' => 400]);
|
||||
}
|
||||
$user_id = get_current_user_id();
|
||||
$today_points = $this->today_points_exchange_total($user_id);
|
||||
if ($today_points + $points > $daily_max) {
|
||||
return new \WP_Error('aksrt_exchange_daily_limit', sprintf(__('今日购买积分上限为 %d,当前还可购买 %d', 'aksrt'), $daily_max, max(0, $daily_max - $today_points)), ['status' => 400]);
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'points-exchange', $user_id);
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $operation_key;
|
||||
}
|
||||
try {
|
||||
$exchange_ref = random_int(1, PHP_INT_MAX);
|
||||
} catch (\Throwable $e) {
|
||||
$exchange_ref = (int) round(microtime(true) * 1000000);
|
||||
$site_now = current_datetime();
|
||||
$start = (clone $site_now)->setTime(0, 0, 0);
|
||||
$end = (clone $start)->modify('+1 day');
|
||||
$utc = new \DateTimeZone('UTC');
|
||||
$start_gmt = $start->setTimezone($utc)->format('Y-m-d H:i:s.u');
|
||||
$end_gmt = $end->setTimezone($utc)->format('Y-m-d H:i:s.u');
|
||||
$guard = static function () use ($user_id, $points, $daily_max, $start_gmt, $end_gmt) {
|
||||
$used = \AKSRT\Ledger\LedgerQuery::get_instance()->sum_credits($user_id, 'points_exchange', $start_gmt, $end_gmt);
|
||||
if ($used + $points > $daily_max) {
|
||||
return new \WP_Error('aksrt_exchange_daily_limit', sprintf(__('今日购买积分上限为 %d,当前还可购买 %d', 'aksrt'), $daily_max, max(0, $daily_max - $used)), ['status' => 400]);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
$business_mutation = static function () use ($user_id, $amount, $points, $operation_key) {
|
||||
if (class_exists('\\AKSRT\\Payment\\CommissionLedger')) {
|
||||
return \AKSRT\Payment\CommissionLedger::get_instance()->record_points_exchange($user_id, $amount->decimal(), $points, $operation_key);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
$result = \AKSRT\Ledger\LedgerService::get_instance()->exchange(
|
||||
$user_id,
|
||||
$amount->minor(),
|
||||
$points,
|
||||
$operation_key,
|
||||
'points_exchange',
|
||||
['type' => 'points_exchange', 'key' => $operation_key],
|
||||
sprintf(__('余额兑换 %d 积分', 'aksrt'), $points),
|
||||
['rate' => $rate],
|
||||
$guard,
|
||||
$business_mutation
|
||||
);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
$ref = ['type' => 'points_exchange', 'id' => $exchange_ref];
|
||||
$deduct = Balance::deduct($user_id, (float) $amount, 'points_exchange_out', $ref, sprintf(__('余额兑换 %d 积分', 'aksrt'), $points));
|
||||
if (is_wp_error($deduct)) return $deduct;
|
||||
$add = Credits::add($user_id, $points, 'points_exchange_in', $ref, sprintf(__('余额兑换:扣除 %d 余额', 'aksrt'), $amount));
|
||||
if (is_wp_error($add)) {
|
||||
$refund = Balance::add($user_id, (float) $amount, 'points_exchange_refund', $ref, __('积分入账失败,已退回兑换余额', 'aksrt'));
|
||||
if (is_wp_error($refund)) return new \WP_Error('aksrt_exchange_recovery_failed', __('积分入账失败,余额退回失败,请联系管理员', 'aksrt'), ['status' => 500]);
|
||||
return $add;
|
||||
}
|
||||
do_action('aksrt_points_exchange_completed', $user_id, (float) $amount, $points, (string) $exchange_ref);
|
||||
return rest_ensure_response(['success' => true, 'data' => [
|
||||
'message' => sprintf(__('兑换成功,获得 %d 积分', 'aksrt'), $points),
|
||||
'wallet_balance' => Balance::balance($user_id),
|
||||
'credit_balance' => Credits::balance($user_id),
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
'wallet_balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
|
||||
'credit_balance' => $result->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
'points' => $points,
|
||||
'reload' => true,
|
||||
]]);
|
||||
}
|
||||
|
||||
private function today_points_exchange_total(int $user_id): int {
|
||||
if ($user_id <= 0 || !class_exists('AKSRT\\Core\\DBMigrator')) return 0;
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('credit_log');
|
||||
if ($table === '') return 0;
|
||||
$site_now = current_datetime();
|
||||
$start = (clone $site_now)->setTime(0, 0, 0);
|
||||
$end = (clone $start)->modify('+1 day');
|
||||
$utc = new \DateTimeZone('UTC');
|
||||
$sql = "SELECT COALESCE(SUM(delta), 0) FROM {$table} WHERE user_id = %d AND reason = %s AND created_at >= %s AND created_at < %s";
|
||||
$start_gmt = $start->setTimezone($utc)->format('Y-m-d H:i:s');
|
||||
$end_gmt = $end->setTimezone($utc)->format('Y-m-d H:i:s');
|
||||
return max(0, (int) $wpdb->get_var($wpdb->prepare($sql, $user_id, 'points_exchange_in', $start_gmt, $end_gmt)));
|
||||
private function percentage_minor(int $amount, int $rate): int {
|
||||
if ($amount <= 0 || $rate <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (intdiv($amount, 100) * $rate) + intdiv(($amount % 100) * $rate, 100);
|
||||
}
|
||||
|
||||
public function rest_balance_withdraw(\WP_REST_Request $request) {
|
||||
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
|
||||
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
|
||||
/** @return int|\WP_Error */
|
||||
private function multiply_non_negative(int $left, int $right) {
|
||||
if ($left < 0 || $right < 0 || ($right > 0 && $left > intdiv(PHP_INT_MAX, $right))) {
|
||||
return new \WP_Error('aksrt_ledger_overflow', __('资产金额超出可用范围', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
|
||||
if (empty($settings['pay_balance_withdraw_s'])) {
|
||||
return new \WP_Error('aksrt_withdraw_closed', __('余额提现未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if (!class_exists('\AKSRT\Payment\Withdrawal')) {
|
||||
return new \WP_Error('aksrt_withdraw_unavailable', __('提现系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$params = $request->get_json_params();
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$user_id = get_current_user_id();
|
||||
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('balance_withdraw', $user_id)) {
|
||||
return new \WP_Error('aksrt_withdraw_forbidden', __('当前账号无权提现', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$raw_amount = $params['amount'] ?? 0;
|
||||
if (filter_var($raw_amount, FILTER_VALIDATE_INT) === false || (int) $raw_amount <= 0) {
|
||||
return new \WP_Error('aksrt_withdraw_amount', __('提现金额必须为正整数', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$amount = (int) $raw_amount;
|
||||
$method = sanitize_key((string) ($params['method'] ?? 'alipay'));
|
||||
$method = $method !== '' ? $method : 'alipay';
|
||||
$account = sanitize_textarea_field((string) ($params['account_info'] ?? $params['account'] ?? ''));
|
||||
$withdraw_settings = class_exists('\\AKSRT\\Settings\\Settings')
|
||||
? \AKSRT\Settings\Settings::get_withdrawal_settings()
|
||||
: ['min_amount' => 50, 'service_charge_rate' => 5, 'daily_count' => 3, 'daily_limit' => 50, 'reset_time' => '00:00'];
|
||||
$lowest = (float) $withdraw_settings['min_amount'];
|
||||
if ($amount < $lowest) {
|
||||
return new \WP_Error('withdraw_min_amount', sprintf(__('提现金额低于最低提现门槛(%s 元)', 'aksrt'), (string) $lowest), ['status' => 400]);
|
||||
}
|
||||
if ($account === '') {
|
||||
return new \WP_Error('aksrt_withdraw_account', __('请填写收款账号信息', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (!in_array($method, (array) ($withdraw_settings['payout_channels'] ?? []), true)) {
|
||||
return new \WP_Error('aksrt_withdraw_channel', __('当前收款渠道不可用', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$result = \AKSRT\Payment\Withdrawal::get_instance()->create_balance_request($user_id, $amount, $method, $account, $withdraw_settings);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'message' => __('提现申请已提交,请等待处理', 'aksrt'),
|
||||
'withdrawal_id' => $result['withdrawal_id'],
|
||||
'amount' => $amount,
|
||||
'net_amount' => $result['net_amount'],
|
||||
'wallet_balance' => $result['wallet_balance'],
|
||||
'remaining_count' => $result['usage']['remaining_count'],
|
||||
'remaining_amount' => $result['usage']['remaining_amount'],
|
||||
],
|
||||
]);
|
||||
return $left * $right;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Credits;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class Credits {
|
||||
|
||||
/** user_meta 键名 */
|
||||
const META_KEY = 'aksrt_credits';
|
||||
|
||||
/**
|
||||
* 取用户余额。
|
||||
*/
|
||||
public static function balance(int $user_id): int {
|
||||
if ($user_id <= 0) return 0;
|
||||
$val = get_user_meta($user_id, self::META_KEY, true);
|
||||
return is_numeric($val) ? (int) $val : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible alias for credit spending.
|
||||
*/
|
||||
public static function get_balance(int $user_id): int {
|
||||
return self::balance($user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加积分。
|
||||
*
|
||||
* @param int $user_id
|
||||
* @param int $delta 必须 > 0
|
||||
* @param string $reason 例 register/signin/comment/referral/admin
|
||||
* @param array $ref ['type' => 'post|comment|user|referral', 'id' => int]
|
||||
* @return int|\WP_Error 新余额或错误
|
||||
*/
|
||||
public static function add(int $user_id, int $delta, string $reason, array $ref = [], string $remark = '', bool $dispatch_hooks = true) {
|
||||
if ($user_id <= 0) return new \WP_Error('aksrt_credits_user', __('用户无效', 'aksrt'));
|
||||
if ($delta <= 0) return new \WP_Error('aksrt_credits_delta', __('增加积分必须为正数', 'aksrt'));
|
||||
|
||||
$new_balance = self::atomic_update($user_id, $delta, false);
|
||||
if ($new_balance === null) {
|
||||
return new \WP_Error('aksrt_credits_db', __('积分写入失败', 'aksrt'));
|
||||
}
|
||||
self::log($user_id, $delta, $new_balance, $reason, $ref, $remark);
|
||||
|
||||
if ($dispatch_hooks) {
|
||||
do_action('aksrt_credits_added', $user_id, $delta, $reason, $new_balance);
|
||||
}
|
||||
return $new_balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扣减积分。
|
||||
*
|
||||
* @return int|\WP_Error 新余额或错误
|
||||
*/
|
||||
public static function deduct(int $user_id, int $cost, string $reason, array $ref = [], string $remark = '') {
|
||||
if ($user_id <= 0) return new \WP_Error('aksrt_credits_user', __('用户无效', 'aksrt'));
|
||||
if ($cost < 0) return new \WP_Error('aksrt_credits_delta', __('扣减积分必须为非负数', 'aksrt'));
|
||||
if ($cost === 0) {
|
||||
return self::balance($user_id);
|
||||
}
|
||||
|
||||
$new_balance = self::atomic_update($user_id, $cost, true);
|
||||
if ($new_balance === null) {
|
||||
$current = self::balance($user_id);
|
||||
return new \WP_Error('aksrt_credits_insufficient', __('积分不足', 'aksrt'), [
|
||||
'balance' => $current,
|
||||
'cost' => $cost,
|
||||
]);
|
||||
}
|
||||
|
||||
self::log($user_id, -$cost, $new_balance, $reason, $ref, $remark);
|
||||
|
||||
do_action('aksrt_credits_deducted', $user_id, $cost, $reason, $new_balance);
|
||||
return $new_balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子加减:用单条 SQL UPDATE 修改 user_meta,避免 read-modify-write 竞态。
|
||||
*
|
||||
* @param int $user_id
|
||||
* @param int $abs_delta 绝对值
|
||||
* @param bool $is_deduct true=扣减(带余额检查),false=增加
|
||||
* @return int|null 新余额;失败(余额不足或 DB 错误)返回 null
|
||||
*/
|
||||
private static function atomic_update(int $user_id, int $abs_delta, bool $is_deduct): ?int {
|
||||
global $wpdb;
|
||||
|
||||
// 确保 meta 行存在(首次扣费可能没有 row)
|
||||
$existing = get_user_meta($user_id, self::META_KEY, true);
|
||||
if ($existing === '' || $existing === false || $existing === null) {
|
||||
update_user_meta($user_id, self::META_KEY, 0);
|
||||
}
|
||||
|
||||
$meta_key = self::META_KEY;
|
||||
|
||||
if ($is_deduct) {
|
||||
// 扣减:仅当 meta_value >= cost 时才更新
|
||||
$sql = $wpdb->prepare(
|
||||
"UPDATE {$wpdb->usermeta}
|
||||
SET meta_value = CAST(meta_value AS SIGNED) - %d
|
||||
WHERE user_id = %d
|
||||
AND meta_key = %s
|
||||
AND CAST(meta_value AS SIGNED) >= %d",
|
||||
$abs_delta,
|
||||
$user_id,
|
||||
$meta_key,
|
||||
$abs_delta
|
||||
);
|
||||
} else {
|
||||
$sql = $wpdb->prepare(
|
||||
"UPDATE {$wpdb->usermeta}
|
||||
SET meta_value = CAST(meta_value AS SIGNED) + %d
|
||||
WHERE user_id = %d
|
||||
AND meta_key = %s",
|
||||
$abs_delta,
|
||||
$user_id,
|
||||
$meta_key
|
||||
);
|
||||
}
|
||||
|
||||
$affected = $wpdb->query($sql);
|
||||
if ($affected === false || $affected === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 失效 user meta cache,下次 get_user_meta 会重读
|
||||
wp_cache_delete($user_id, 'user_meta');
|
||||
|
||||
$new = (int) get_user_meta($user_id, self::META_KEY, true);
|
||||
return $new;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写一条流水。
|
||||
*
|
||||
* @param int $user_id
|
||||
* @param int $delta 本次变动(正/负/0)
|
||||
* @param int $balance_after 操作后余额
|
||||
* @param string $reason
|
||||
* @param array $ref
|
||||
*/
|
||||
public static function log(int $user_id, int $delta, int $balance_after, string $reason, array $ref = [], string $remark = ''): void {
|
||||
global $wpdb;
|
||||
|
||||
$table = \AKSRT\Core\DBMigrator::table('credit_log');
|
||||
if (!$table) return;
|
||||
|
||||
$ref_type = isset($ref['type']) ? sanitize_key((string) $ref['type']) : null;
|
||||
$ref_id = isset($ref['id']) ? (int) $ref['id'] : null;
|
||||
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
[
|
||||
'user_id' => $user_id,
|
||||
'delta' => $delta,
|
||||
'balance_after' => $balance_after,
|
||||
'reason' => substr($reason, 0, 64),
|
||||
'remark' => $remark !== '' ? substr($remark, 0, 255) : null,
|
||||
'ref_type' => $ref_type,
|
||||
'ref_id' => $ref_id,
|
||||
'ip' => self::client_ip(),
|
||||
'ua' => self::client_ua(),
|
||||
'created_at' => current_time('mysql', true),
|
||||
],
|
||||
['%d', '%d', '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取流水(管理员页面用)。
|
||||
*
|
||||
* @param array $args { user_id, reason, page, per_page, date_from, date_to }
|
||||
* @return array { items, total, page, per_page }
|
||||
*/
|
||||
public static function get_log(array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$table = \AKSRT\Core\DBMigrator::table('credit_log');
|
||||
if (!$table) {
|
||||
return ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 20];
|
||||
}
|
||||
|
||||
$page = max(1, (int) ($args['page'] ?? 1));
|
||||
$per_page = min(100, max(1, (int) ($args['per_page'] ?? 20)));
|
||||
$offset = ($page - 1) * $per_page;
|
||||
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
|
||||
if (!empty($args['user_id'])) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = (int) $args['user_id'];
|
||||
}
|
||||
if (!empty($args['reason'])) {
|
||||
$where[] = 'reason = %s';
|
||||
$params[] = sanitize_key((string) $args['reason']);
|
||||
}
|
||||
if (!empty($args['date_from'])) {
|
||||
$where[] = 'created_at >= %s';
|
||||
$params[] = (string) $args['date_from'];
|
||||
}
|
||||
if (!empty($args['date_to'])) {
|
||||
$where[] = 'created_at <= %s';
|
||||
$params[] = (string) $args['date_to'];
|
||||
}
|
||||
|
||||
$where_sql = implode(' AND ', $where);
|
||||
|
||||
// total
|
||||
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
|
||||
$total = (int) ($params
|
||||
? $wpdb->get_var($wpdb->prepare($count_sql, $params))
|
||||
: $wpdb->get_var($count_sql));
|
||||
|
||||
// items
|
||||
$items_sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d";
|
||||
$items_params = array_merge($params, [$per_page, $offset]);
|
||||
$items = $wpdb->get_results($wpdb->prepare($items_sql, $items_params), ARRAY_A);
|
||||
if (!is_array($items)) $items = [];
|
||||
|
||||
// user 名字预填
|
||||
$uid_set = array_unique(array_map(fn($r) => (int) $r['user_id'], $items));
|
||||
$names = [];
|
||||
foreach ($uid_set as $uid) {
|
||||
$u = get_userdata($uid);
|
||||
if ($u) $names[$uid] = $u->display_name;
|
||||
}
|
||||
foreach ($items as &$row) {
|
||||
$row['user_display_name'] = $names[(int) $row['user_id']] ?? '#' . $row['user_id'];
|
||||
$row['delta'] = (int) $row['delta'];
|
||||
$row['balance_after'] = (int) $row['balance_after'];
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $per_page,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户余额列表(管理员)。
|
||||
*
|
||||
* @param array $args { search, page, per_page }
|
||||
* @return array
|
||||
*/
|
||||
public static function list_users(array $args = []): array {
|
||||
$page = max(1, (int) ($args['page'] ?? 1));
|
||||
$per_page = min(100, max(1, (int) ($args['per_page'] ?? 20)));
|
||||
|
||||
$q = [
|
||||
'number' => $per_page,
|
||||
'paged' => $page,
|
||||
'orderby' => 'registered',
|
||||
'order' => 'DESC',
|
||||
];
|
||||
if (!empty($args['search'])) {
|
||||
$q['search'] = '*' . esc_attr((string) $args['search']) . '*';
|
||||
$q['search_columns'] = ['user_login', 'user_email', 'display_name', 'user_nicename'];
|
||||
}
|
||||
$user_query = new \WP_User_Query($q);
|
||||
$users = $user_query->get_results();
|
||||
$items = [];
|
||||
foreach ($users as $u) {
|
||||
$items[] = [
|
||||
'id' => (int) $u->ID,
|
||||
'login' => $u->user_login,
|
||||
'email' => $u->user_email,
|
||||
'display_name' => $u->display_name,
|
||||
'balance' => self::balance((int) $u->ID),
|
||||
'registered' => $u->user_registered,
|
||||
];
|
||||
}
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => (int) $user_query->get_total(),
|
||||
'page' => $page,
|
||||
'per_page' => $per_page,
|
||||
];
|
||||
}
|
||||
|
||||
// ---------- 工具方法 ----------
|
||||
|
||||
private static function client_ip(): string {
|
||||
if (function_exists('aksrt_get_client_ip')) {
|
||||
return (string) aksrt_get_client_ip();
|
||||
}
|
||||
if (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||
return (string) $_SERVER['REMOTE_ADDR'];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function client_ua(): string {
|
||||
$ua = isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
|
||||
return substr($ua, 0, 255);
|
||||
}
|
||||
}
|
||||
+18
-46
@@ -40,17 +40,10 @@ class PointsFree {
|
||||
*/
|
||||
public static function settings(): array {
|
||||
$all = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
|
||||
$credit = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_credit_settings() : [];
|
||||
|
||||
$tasks = [];
|
||||
foreach (self::catalog() as $key => $meta) {
|
||||
$flat_key = 'points_free_' . $key;
|
||||
if (array_key_exists($flat_key, $all)) {
|
||||
$tasks[$key] = max(0, (int) $all[$flat_key]);
|
||||
continue;
|
||||
}
|
||||
// Fallback mapping from legacy / parallel settings.
|
||||
$tasks[$key] = self::legacy_task_points($key, $all, $credit, (int) $meta[2]);
|
||||
$tasks[$key] = max(0, (int) ($all[$flat_key] ?? $meta[2]));
|
||||
}
|
||||
|
||||
$day_max = array_key_exists('points_earn_daily_limit', $all)
|
||||
@@ -75,7 +68,7 @@ class PointsFree {
|
||||
* Whether free points system can run (credits enabled).
|
||||
*/
|
||||
public static function enabled(): bool {
|
||||
return class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::credits_enabled() && class_exists('AKSRT\\Credits\\Credits');
|
||||
return class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::credits_enabled();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,19 +136,31 @@ class PointsFree {
|
||||
}
|
||||
|
||||
$reason = 'free_' . $task_key;
|
||||
$result = Credits::add(
|
||||
$today = self::today_key();
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('points-free', [$user_id, $task_key, $today]);
|
||||
$result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
|
||||
$user_id,
|
||||
\AKSRT\Ledger\AssetType::POINTS,
|
||||
$points,
|
||||
$operation_key,
|
||||
$reason,
|
||||
$ref,
|
||||
self::catalog()[$task_key][0] ?? $task_key
|
||||
self::catalog()[$task_key][0] ?? $task_key,
|
||||
0,
|
||||
['task' => $task_key, 'date' => $today],
|
||||
static function () use ($user_id, $points, $task_key) {
|
||||
if (self::remaining_today($user_id) < $points) {
|
||||
return new \WP_Error('aksrt_points_daily_limit', __('今日积分奖励已达上限', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
self::bump_detail($user_id, $points);
|
||||
self::mark_task_done_today($user_id, $task_key);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
self::bump_detail($user_id, $points);
|
||||
self::mark_task_done_today($user_id, $task_key);
|
||||
return $points;
|
||||
}
|
||||
|
||||
@@ -297,37 +302,4 @@ class PointsFree {
|
||||
update_user_meta($user_id, self::META_DETAIL, $detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $all
|
||||
* @param array<string,mixed> $credit
|
||||
*/
|
||||
private static function legacy_task_points(string $key, array $all, array $credit, int $default): int {
|
||||
switch ($key) {
|
||||
case 'sign_up':
|
||||
return max(0, (int) ($credit['register_bonus'] ?? $default));
|
||||
case 'sign_in':
|
||||
$signin = is_array($credit['daily_signin'] ?? null) ? $credit['daily_signin'] : [];
|
||||
if (isset($signin['enabled']) && empty($signin['enabled'])) {
|
||||
return 0;
|
||||
}
|
||||
return max(0, (int) ($signin['base'] ?? $default));
|
||||
case 'login':
|
||||
return max(0, (int) ($all['points_earn_login'] ?? $default));
|
||||
case 'post_new':
|
||||
return max(0, (int) ($all['points_earn_post'] ?? $default));
|
||||
case 'post_like':
|
||||
return max(0, (int) ($all['points_earn_like_received'] ?? $default));
|
||||
case 'comment_new':
|
||||
if (isset($all['points_earn_comment'])) {
|
||||
return max(0, (int) $all['points_earn_comment']);
|
||||
}
|
||||
$cr = is_array($credit['comment_reward'] ?? null) ? $credit['comment_reward'] : [];
|
||||
if (isset($cr['enabled']) && empty($cr['enabled'])) {
|
||||
return 0;
|
||||
}
|
||||
return max(0, (int) ($cr['amount'] ?? $default));
|
||||
default:
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+118
-64
@@ -27,33 +27,36 @@ class Signin {
|
||||
register_rest_route('aksrt/v1', '/signin/today', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_today'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_rest_permission'],
|
||||
]);
|
||||
register_rest_route('aksrt/v1', '/signin', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_signin'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_rest_permission'],
|
||||
]);
|
||||
register_rest_route('aksrt/v1', '/signin/history', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_history'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_rest_permission'],
|
||||
]);
|
||||
register_rest_route('aksrt/v1', '/signin/details', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_details_html'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_rest_permission'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function check_rest_permission(\WP_REST_Request $request) {
|
||||
if (!is_user_logged_in()) {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
$nonce = (string) $request->get_header('X-WP-Nonce');
|
||||
if ($nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) {
|
||||
return new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** GET /signin/today */
|
||||
public function rest_today(\WP_REST_Request $request) {
|
||||
$user_id = get_current_user_id();
|
||||
@@ -99,10 +102,6 @@ class Signin {
|
||||
}
|
||||
|
||||
$today_str = $this->today_date();
|
||||
if ($this->get_record($user_id, $today_str)) {
|
||||
return new \WP_Error('aksrt_signin_dup', __('今日已签到', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
|
||||
// 人机验证(若启用)
|
||||
if (function_exists('aksrt_require_captcha')) {
|
||||
$body = $request->get_json_params();
|
||||
@@ -116,59 +115,75 @@ class Signin {
|
||||
}
|
||||
}
|
||||
|
||||
$current_streak = $this->compute_current_streak($user_id, $today_str);
|
||||
$new_streak = $current_streak + 1;
|
||||
$reward = $this->calc_reward($new_streak, $signin_cfg);
|
||||
$existing_record = $this->get_record($user_id, $today_str);
|
||||
if (is_array($existing_record)) {
|
||||
$new_streak = max(1, (int) ($existing_record['streak'] ?? 1));
|
||||
$reward = max(0, (int) ($existing_record['reward'] ?? 0));
|
||||
} else {
|
||||
$current_streak = $this->compute_current_streak($user_id, $today_str);
|
||||
$new_streak = $current_streak + 1;
|
||||
$reward = $this->calc_reward($new_streak, $signin_cfg);
|
||||
}
|
||||
|
||||
$table = \AKSRT\Core\DBMigrator::table('signin_log');
|
||||
if (!$table) {
|
||||
return new \WP_Error('aksrt_signin_db', __('数据表不存在', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
$inserted = $wpdb->insert(
|
||||
$table,
|
||||
[
|
||||
'user_id' => $user_id,
|
||||
'signin_date' => $today_str,
|
||||
'streak' => $new_streak,
|
||||
'reward' => $reward,
|
||||
],
|
||||
['%d', '%s', '%d', '%d']
|
||||
);
|
||||
if ($inserted === false) {
|
||||
return new \WP_Error('aksrt_signin_dup', __('签到失败', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('signin', [$user_id, $today_str]);
|
||||
$transaction_id = null;
|
||||
$replayed = false;
|
||||
if ($reward > 0) {
|
||||
$guard = static function () use ($wpdb, $table, $user_id, $today_str) {
|
||||
$existing = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE user_id = %d AND signin_date = %s FOR UPDATE",
|
||||
$user_id,
|
||||
$today_str
|
||||
));
|
||||
return $existing === null
|
||||
? true
|
||||
: new \WP_Error('aksrt_signin_dup', __('今日已签到', 'aksrt'), ['status' => 409]);
|
||||
};
|
||||
$mutation = static function () use ($wpdb, $table, $user_id, $today_str, $new_streak, $reward) {
|
||||
$inserted = $wpdb->insert($table, [
|
||||
'user_id' => $user_id,
|
||||
'signin_date' => $today_str,
|
||||
'streak' => $new_streak,
|
||||
'reward' => $reward,
|
||||
], ['%d', '%s', '%d', '%d']);
|
||||
return $inserted === 1
|
||||
? true
|
||||
: new \WP_Error('aksrt_signin_db', __('签到记录写入失败', 'aksrt'), ['status' => 500]);
|
||||
};
|
||||
$ledger_result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
|
||||
$user_id,
|
||||
\AKSRT\Ledger\AssetType::POINTS,
|
||||
$reward,
|
||||
$operation_key,
|
||||
'signin',
|
||||
['type' => 'signin', 'key' => $today_str],
|
||||
__('每日签到奖励', 'aksrt'),
|
||||
$user_id,
|
||||
['signin_date' => $today_str, 'streak' => $new_streak],
|
||||
$mutation,
|
||||
$guard
|
||||
);
|
||||
if (is_wp_error($ledger_result)) {
|
||||
return $ledger_result;
|
||||
}
|
||||
$transaction_id = $ledger_result->transaction_id;
|
||||
$replayed = $ledger_result->replayed;
|
||||
} else {
|
||||
$stored = $this->store_zero_reward_signin($user_id, $today_str, $new_streak);
|
||||
if (is_wp_error($stored)) {
|
||||
return $stored;
|
||||
}
|
||||
$replayed = $stored;
|
||||
}
|
||||
|
||||
do_action('aksrt_user_signed_in', $user_id, $reward);
|
||||
|
||||
if ($reward > 0) {
|
||||
if (class_exists('AKSRT\\Credits\\PointsFree')) {
|
||||
$granted = PointsFree::grant_amount($user_id, $reward, 'sign_in', ['type' => 'user', 'id' => $user_id]);
|
||||
if (is_wp_error($granted)) {
|
||||
if (class_exists('AKSRT\\Credits\\Credits')) {
|
||||
Credits::add($user_id, $reward, 'signin', ['type' => 'user', 'id' => $user_id]);
|
||||
}
|
||||
} else {
|
||||
$reward = (int) $granted;
|
||||
}
|
||||
} elseif (class_exists('AKSRT\\Credits\\Credits')) {
|
||||
Credits::add($user_id, $reward, 'signin', ['type' => 'user', 'id' => $user_id]);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep lightweight meta for header/badge consumers (legacy checkin_all_day).
|
||||
update_user_meta($user_id, 'aksrt_signin_' . $today_str, 1);
|
||||
$all_day = (int) get_user_meta($user_id, 'aksrt_checkin_all_day', true);
|
||||
if ($all_day <= 0) {
|
||||
// Fallback to legacy key if migrated users only have that meta.
|
||||
$all_day = (int) get_user_meta($user_id, 'checkin_all_day', true);
|
||||
}
|
||||
$all_day = $all_day + 1;
|
||||
update_user_meta($user_id, 'aksrt_checkin_all_day', $all_day);
|
||||
update_user_meta($user_id, 'checkin_all_day', $all_day); // legacy alias
|
||||
update_user_meta($user_id, 'aksrt_checkin_continuous_day', $new_streak);
|
||||
update_user_meta($user_id, 'checkin_continuous_day', $new_streak); // legacy alias
|
||||
do_action('aksrt_checkin_done', $user_id);
|
||||
if (!$replayed) {
|
||||
$this->after_signin_committed($user_id, $today_str, $new_streak, $reward);
|
||||
}
|
||||
|
||||
return new \WP_REST_Response([
|
||||
'success' => true,
|
||||
@@ -180,10 +195,49 @@ class Signin {
|
||||
),
|
||||
'streak' => $new_streak,
|
||||
'reward' => $reward,
|
||||
'balance' => class_exists('AKSRT\Credits\Credits') ? Credits::balance($user_id) : 0,
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
'transaction_id' => $transaction_id,
|
||||
'replayed' => $replayed,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/** @return bool|\WP_Error True means the existing row was replayed. */
|
||||
private function store_zero_reward_signin(int $user_id, string $date, int $streak) {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('signin_log');
|
||||
if ($wpdb->query('START TRANSACTION') === false) {
|
||||
return new \WP_Error('aksrt_signin_db', __('无法开启签到事务', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$existing = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE user_id = %d AND signin_date = %s FOR UPDATE",
|
||||
$user_id,
|
||||
$date
|
||||
));
|
||||
if ($existing !== null) {
|
||||
$wpdb->query('COMMIT');
|
||||
return true;
|
||||
}
|
||||
$inserted = $wpdb->insert($table, [
|
||||
'user_id' => $user_id,
|
||||
'signin_date' => $date,
|
||||
'streak' => $streak,
|
||||
'reward' => 0,
|
||||
], ['%d', '%s', '%d', '%d']);
|
||||
if ($inserted !== 1 || $wpdb->query('COMMIT') === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return new \WP_Error('aksrt_signin_db', __('签到记录写入失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function after_signin_committed(int $user_id, string $date, int $streak, int $reward): void {
|
||||
update_user_meta($user_id, 'aksrt_signin_' . $date, 1);
|
||||
update_user_meta($user_id, 'aksrt_checkin_all_day', $this->get_total_days($user_id));
|
||||
update_user_meta($user_id, 'aksrt_checkin_continuous_day', $streak);
|
||||
do_action('aksrt_user_signed_in', $user_id, $reward);
|
||||
do_action('aksrt_checkin_done', $user_id);
|
||||
}
|
||||
|
||||
/** GET /signin/history — last N days for calendar/modals. */
|
||||
public function rest_history(\WP_REST_Request $request) {
|
||||
$user_id = get_current_user_id();
|
||||
@@ -260,7 +314,7 @@ class Signin {
|
||||
$rec = $this->get_record($user_id, $today);
|
||||
$streak = $this->compute_current_streak($user_id, $today);
|
||||
$total = $this->get_total_days($user_id);
|
||||
$balance = class_exists('\AKSRT\Credits\Credits') ? (int) Credits::balance($user_id) : 0;
|
||||
$balance = \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS);
|
||||
$currency = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_currency_name()
|
||||
: __('积分', 'aksrt');
|
||||
|
||||
@@ -48,6 +48,7 @@ final class ApplicationRepository {
|
||||
$row = wp_parse_args($data, [
|
||||
'application_uuid' => wp_generate_uuid4(),
|
||||
'client_request_id' => wp_generate_uuid4(),
|
||||
'request_payload_hash' => '',
|
||||
'user_id' => 0,
|
||||
'type' => 'personal',
|
||||
'channel' => 'cloud',
|
||||
@@ -55,7 +56,7 @@ final class ApplicationRepository {
|
||||
'status' => 'created',
|
||||
'public_name' => '',
|
||||
'public_desc' => '',
|
||||
'fee_amount' => 0,
|
||||
'fee_amount' => '0.00',
|
||||
'fee_status' => 'not_required',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
@@ -75,6 +76,13 @@ final class ApplicationRepository {
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function find_by_id_for_update(int $id): ?array {
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table} WHERE id = %d FOR UPDATE", $id), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function find_by_uuid(string $uuid): ?array {
|
||||
global $wpdb;
|
||||
@@ -164,12 +172,6 @@ final class ApplicationRepository {
|
||||
$formats[$index] = '%d';
|
||||
}
|
||||
}
|
||||
foreach (['fee_amount'] as $float_key) {
|
||||
$index = array_search($float_key, array_keys($fields), true);
|
||||
if ($index !== false) {
|
||||
$formats[$index] = '%f';
|
||||
}
|
||||
}
|
||||
$updated = $wpdb->update($this->table, $fields, ['id' => $id, 'status' => $expected], $formats, ['%d', '%s']) === 1;
|
||||
if ($updated) {
|
||||
self::clear_snapshot_cache();
|
||||
@@ -229,7 +231,11 @@ final class ApplicationRepository {
|
||||
$row[$nullable_key] = '';
|
||||
}
|
||||
}
|
||||
$row['fee_amount'] = round((float) ($row['fee_amount'] ?? 0), 2);
|
||||
try {
|
||||
$row['fee_amount'] = \AKSRT\Ledger\AssetAmount::balance((string) ($row['fee_amount'] ?? ''))->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$row['fee_amount'] = '';
|
||||
}
|
||||
$evidence = json_decode((string) ($row['evidence_ids'] ?? ''), true);
|
||||
$row['evidence_ids'] = is_array($evidence) ? array_values(array_map('intval', $evidence)) : [];
|
||||
return $row;
|
||||
|
||||
+284
-140
@@ -9,6 +9,11 @@ namespace AKSRT\Identity;
|
||||
|
||||
use AKSRT\Identity\Provider\ProviderFactory;
|
||||
use AKSRT\Identity\Provider\ProviderResult;
|
||||
use AKSRT\Ledger\AssetAmount;
|
||||
use AKSRT\Ledger\AssetType;
|
||||
use AKSRT\Ledger\LedgerResult;
|
||||
use AKSRT\Ledger\LedgerService;
|
||||
use AKSRT\Ledger\OperationKey;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
@@ -25,7 +30,7 @@ final class ApplicationService {
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function create(int $user_id, array $input) {
|
||||
public function create(int $user_id, array $input, string $request_id, string $operation_key) {
|
||||
$settings = SettingsRuntime::get();
|
||||
if (empty($settings['enabled']) || empty($settings['allow_apply'])) {
|
||||
return new \WP_Error('aksrt_identity_disabled', __('暂不能申请身份认证', 'aksrt'));
|
||||
@@ -33,25 +38,14 @@ final class ApplicationService {
|
||||
if (!is_ssl()) {
|
||||
return new \WP_Error('aksrt_identity_https', __('身份认证必须通过 HTTPS 发起', 'aksrt'));
|
||||
}
|
||||
if ($this->applications->verified_for_user($user_id)) {
|
||||
return new \WP_Error('aksrt_identity_verified', __('您已是认证用户', 'aksrt'));
|
||||
}
|
||||
if ($this->applications->active_for_user($user_id)) {
|
||||
return new \WP_Error('aksrt_identity_active', __('您已有进行中的认证申请', 'aksrt'));
|
||||
}
|
||||
|
||||
$type = sanitize_key((string) ($input['type'] ?? ''));
|
||||
if (!in_array($type, ['personal', 'enterprise'], true)) {
|
||||
return new \WP_Error('aksrt_identity_type', __('请选择有效的认证类型', 'aksrt'));
|
||||
}
|
||||
$request_id = sanitize_text_field((string) ($input['client_request_id'] ?? ''));
|
||||
if (!wp_is_uuid($request_id)) {
|
||||
$request_id = strtolower(trim($request_id));
|
||||
if (!wp_is_uuid($request_id) || $operation_key === '') {
|
||||
return new \WP_Error('aksrt_identity_request_id', __('请求标识无效', 'aksrt'));
|
||||
}
|
||||
$existing = $this->applications->find_by_client_request($user_id, $request_id);
|
||||
if ($existing) {
|
||||
return $this->view($existing);
|
||||
}
|
||||
|
||||
$person_name = sanitize_text_field((string) ($input['person_name'] ?? ''));
|
||||
$id_number = strtoupper(preg_replace('/\s+/', '', sanitize_text_field((string) ($input['id_number'] ?? ''))) ?: '');
|
||||
@@ -66,16 +60,41 @@ final class ApplicationService {
|
||||
if (empty($input['consent'])) {
|
||||
return new \WP_Error('aksrt_identity_consent', __('请先同意身份信息处理说明', 'aksrt'));
|
||||
}
|
||||
$request_payload_hash = $this->request_payload_hash($type, $person_name, $id_number, $company_name, $company_code);
|
||||
$existing = $this->applications->find_by_client_request($user_id, $request_id);
|
||||
if ($existing) {
|
||||
if (!hash_equals((string) ($existing['request_payload_hash'] ?? ''), $request_payload_hash)) {
|
||||
return new \WP_Error('aksrt_identity_idempotency_conflict', __('相同操作标识已用于其他认证申请', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
if ((string) ($existing['status'] ?? '') !== 'created') {
|
||||
return $this->view($existing, LedgerService::get_instance()->result_for_operation($operation_key), true);
|
||||
}
|
||||
}
|
||||
if (!$existing && $this->applications->verified_for_user($user_id)) {
|
||||
return new \WP_Error('aksrt_identity_verified', __('您已是认证用户', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
if (!$existing && $this->applications->active_for_user($user_id)) {
|
||||
return new \WP_Error('aksrt_identity_active', __('您已有进行中的认证申请', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$public_name = $type === 'enterprise' ? $this->truncate($company_name, 64) : __('个人认证', 'aksrt');
|
||||
$public_desc = '';
|
||||
|
||||
$type_settings = \AKSRT\User\AuthApply::type_settings($type);
|
||||
$attempt = $this->applications->count_user_type($user_id, $type);
|
||||
$free = max(0, (int) ($type_settings['free_applications'] ?? 0));
|
||||
$fee = $attempt < $free ? 0.0 : max(0, (float) ($type_settings['fee'] ?? 0));
|
||||
$application = $this->applications->create([
|
||||
if ($existing) {
|
||||
$application = $existing;
|
||||
} else {
|
||||
$type_settings = \AKSRT\User\AuthApply::type_settings($type);
|
||||
$attempt = $this->applications->count_user_type($user_id, $type);
|
||||
$free = max(0, (int) ($type_settings['free_applications'] ?? 0));
|
||||
try {
|
||||
$configured_fee = AssetAmount::balance(is_string($type_settings['fee'] ?? null) ? trim($type_settings['fee']) : '');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new \WP_Error('aksrt_identity_fee', __('认证费用配置无效', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$fee = $attempt < $free ? AssetAmount::balance('0.00') : $configured_fee;
|
||||
$application = $this->applications->create([
|
||||
'application_uuid' => wp_generate_uuid4(),
|
||||
'client_request_id' => $request_id,
|
||||
'request_payload_hash' => $request_payload_hash,
|
||||
'user_id' => $user_id,
|
||||
'type' => $type,
|
||||
'provider' => $settings['provider'],
|
||||
@@ -88,22 +107,27 @@ final class ApplicationService {
|
||||
'company_name' => $company_name,
|
||||
'company_code_masked' => $type === 'enterprise' ? SettingsRuntime::mask_id_number($company_code) : '',
|
||||
'company_code_hash' => $type === 'enterprise' ? SettingsRuntime::hmac($company_code) : '',
|
||||
'fee_amount' => $fee,
|
||||
'fee_status' => $fee > 0 ? 'pending' : 'not_required',
|
||||
'fee_amount' => $fee->decimal(),
|
||||
'fee_status' => $fee->minor() > 0 ? 'pending' : 'not_required',
|
||||
'attempt_ordinal' => $attempt + 1,
|
||||
]);
|
||||
]);
|
||||
}
|
||||
if (is_wp_error($application)) {
|
||||
return $application;
|
||||
}
|
||||
|
||||
$provider = ProviderFactory::make((string) $settings['provider']);
|
||||
$provider = ProviderFactory::make((string) $application['provider']);
|
||||
$valid = $provider->validate_config();
|
||||
if (is_wp_error($valid)) {
|
||||
$next = !empty($settings['allow_manual_fallback']) ? 'provider_unsupported' : 'cancelled';
|
||||
$failed = $this->transitions->transition($application, $next, [
|
||||
$failed = $this->transitions->transition($application, $next, [
|
||||
'provider_result_code' => $valid->get_error_code(),
|
||||
'provider_result_summary' => $valid->get_error_message(),
|
||||
], ['source' => 'system', 'event_type' => 'provider_preflight_failed']);
|
||||
], [
|
||||
'source' => 'system',
|
||||
'event_type' => 'provider_preflight_failed',
|
||||
'dedupe_key' => $this->event_dedupe_key($application, $operation_key, $next),
|
||||
]);
|
||||
return is_wp_error($failed) ? $failed : $this->view($failed);
|
||||
}
|
||||
|
||||
@@ -117,29 +141,52 @@ final class ApplicationService {
|
||||
$result = $type === 'personal'
|
||||
? $provider->start_personal($provider_request)
|
||||
: $provider->start_enterprise($provider_request);
|
||||
return $this->apply_provider_result($application, $result, true);
|
||||
return $this->apply_provider_result($application, $result, true, $operation_key);
|
||||
}
|
||||
|
||||
public function refresh(array $application) {
|
||||
public function refresh(array $application, string $operation_key = '') {
|
||||
if (!in_array((string) $application['status'], ['provider_pending', 'provider_processing'], true)) {
|
||||
return $this->view($application);
|
||||
}
|
||||
if (!empty($application['expires_at']) && strtotime((string) $application['expires_at'] . ' UTC') <= time()) {
|
||||
$settings = SettingsRuntime::get();
|
||||
$next = !empty($settings['allow_manual_fallback']) ? 'provider_expired' : 'cancelled';
|
||||
$expired = $this->transitions->transition($application, $next, [
|
||||
$effective_key = $operation_key !== ''
|
||||
? $operation_key
|
||||
: OperationKey::deterministic('identity-expired', [(int) $application['id'], (string) $application['expires_at']]);
|
||||
$expired = $this->transitions->transition($application, $next, [
|
||||
'provider_result_code' => 'session_expired',
|
||||
'provider_result_summary' => __('云认证会话已过期', 'aksrt'),
|
||||
], ['source' => 'system', 'event_type' => 'session_expired']);
|
||||
], [
|
||||
'source' => 'system',
|
||||
'event_type' => 'session_expired',
|
||||
'dedupe_key' => $this->event_dedupe_key($application, $effective_key, $next),
|
||||
]);
|
||||
return is_wp_error($expired) ? $expired : $this->view($expired);
|
||||
}
|
||||
$provider = ProviderFactory::make((string) $application['provider']);
|
||||
return $this->apply_provider_result($application, $provider->query($application), false);
|
||||
return $this->apply_provider_result($application, $provider->query($application), false, $operation_key);
|
||||
}
|
||||
|
||||
public function move_to_manual(array $application, $files) {
|
||||
public function move_to_manual(array $application, $files, string $operation_key) {
|
||||
$payload_hash = $this->evidence->fingerprint($files);
|
||||
if (is_wp_error($payload_hash)) {
|
||||
return $payload_hash;
|
||||
}
|
||||
$manual_dedupe = $this->event_dedupe_key($application, $operation_key, 'manual_pending');
|
||||
$existing_event = (new EventRepository())->find_by_dedupe($manual_dedupe);
|
||||
if (is_array($existing_event) && (int) $existing_event['application_id'] === (int) $application['id']) {
|
||||
$summary = json_decode((string) ($existing_event['summary_json'] ?? ''), true);
|
||||
if (!is_array($summary) || !hash_equals((string) ($summary['payload_hash'] ?? ''), $payload_hash)) {
|
||||
return new \WP_Error('aksrt_identity_idempotency_conflict', __('相同操作标识已用于其他认证材料', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$current = $this->applications->find_by_id((int) $application['id']);
|
||||
return is_array($current)
|
||||
? $this->view($current, LedgerService::get_instance()->result_for_operation($operation_key), true)
|
||||
: new \WP_Error('aksrt_identity_missing', __('认证申请不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
if (!in_array((string) $application['status'], ['created', 'provider_failed', 'provider_unsupported', 'provider_expired'], true)) {
|
||||
return new \WP_Error('aksrt_identity_manual_state', __('该申请当前不能转人工审核', 'aksrt'));
|
||||
return new \WP_Error('aksrt_identity_manual_state', __('该申请当前不能转人工审核', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
if (empty(SettingsRuntime::get()['allow_manual_fallback'])) {
|
||||
return new \WP_Error('aksrt_identity_manual_disabled', __('站点未开放人工审核回退', 'aksrt'));
|
||||
@@ -148,27 +195,32 @@ final class ApplicationService {
|
||||
if (is_wp_error($ids)) {
|
||||
return $ids;
|
||||
}
|
||||
$charge = $this->charge($application);
|
||||
if (is_wp_error($charge)) {
|
||||
$fields = [
|
||||
'channel' => 'manual',
|
||||
'manual_reason' => __('云认证未完成,用户转入人工审核', 'aksrt'),
|
||||
'evidence_ids' => wp_json_encode($ids),
|
||||
];
|
||||
$event = [
|
||||
'source' => 'user',
|
||||
'event_type' => 'manual_fallback',
|
||||
'actor_user_id' => (int) $application['user_id'],
|
||||
'dedupe_key' => $manual_dedupe,
|
||||
'summary' => ['payload_hash' => $payload_hash],
|
||||
];
|
||||
$changed = $this->transition_with_charge($application, 'manual_pending', $fields, $event, $operation_key, ['business_payload_hash' => $payload_hash]);
|
||||
if (is_wp_error($changed)) {
|
||||
$this->evidence->delete($ids);
|
||||
return $charge;
|
||||
return $changed;
|
||||
}
|
||||
$updated = $this->transitions->transition($application, 'manual_pending', [
|
||||
'channel' => 'manual',
|
||||
'manual_reason' => __('云认证未完成,用户转入人工审核', 'aksrt'),
|
||||
'evidence_ids' => wp_json_encode($ids),
|
||||
'fee_status' => $charge,
|
||||
], ['source' => 'user', 'event_type' => 'manual_fallback', 'actor_user_id' => (int) $application['user_id']]);
|
||||
if (is_wp_error($updated)) {
|
||||
$this->evidence->delete($ids);
|
||||
$this->refund($application);
|
||||
return $updated;
|
||||
}
|
||||
return $this->view($updated);
|
||||
$updated = $changed['application'];
|
||||
if ((array) ($updated['evidence_ids'] ?? []) !== array_values(array_map('intval', $ids))) {
|
||||
$this->evidence->delete($ids);
|
||||
}
|
||||
return $this->view($updated, $changed['ledger']);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function view(array $application): array {
|
||||
public function view(array $application, ?LedgerResult $ledger_result = null, bool $replayed = false): array {
|
||||
$stage = (string) $application['status'];
|
||||
$next = 'wait';
|
||||
if (in_array($stage, ['provider_pending', 'provider_processing'], true)) {
|
||||
@@ -178,7 +230,8 @@ final class ApplicationService {
|
||||
} elseif ($stage === 'verified') {
|
||||
$next = 'complete';
|
||||
}
|
||||
return [
|
||||
$balance_minor = LedgerService::get_instance()->balance((int) $application['user_id'], AssetType::BALANCE);
|
||||
return [
|
||||
'uuid' => (string) $application['application_uuid'],
|
||||
'type' => (string) $application['type'],
|
||||
'status' => TransitionService::public_status($stage),
|
||||
@@ -192,103 +245,194 @@ final class ApplicationService {
|
||||
'expires_at' => (string) ($application['expires_at'] ?? ''),
|
||||
'created_at' => (string) $application['created_at'],
|
||||
'poll_interval' => (int) SettingsRuntime::get()['poll_interval_seconds'],
|
||||
'transaction_id' => $ledger_result?->transaction_id ?? 0,
|
||||
'replayed' => $ledger_result?->replayed ?? $replayed,
|
||||
'wallet_balance' => AssetAmount::from_minor(AssetType::BALANCE, $balance_minor)->decimal(),
|
||||
];
|
||||
}
|
||||
|
||||
private function apply_provider_result(array $application, ProviderResult $result, bool $starting) {
|
||||
$data = $result->data;
|
||||
if ($result->decision === ProviderResult::PENDING) {
|
||||
$fee_status = (string) $application['fee_status'];
|
||||
if ($starting) {
|
||||
$charged = $this->charge($application);
|
||||
if (is_wp_error($charged)) {
|
||||
return $charged;
|
||||
}
|
||||
$fee_status = $charged;
|
||||
}
|
||||
$next = $starting ? 'provider_pending' : 'provider_processing';
|
||||
$fields = [
|
||||
'provider_session_id' => sanitize_text_field((string) ($data['provider_session_id'] ?? ($application['provider_session_id'] ?? ''))),
|
||||
'provider_request_id' => sanitize_text_field((string) ($data['request_id'] ?? ($application['provider_request_id'] ?? ''))),
|
||||
'redirect_url' => esc_url_raw((string) ($data['redirect_url'] ?? ($application['redirect_url'] ?? ''))),
|
||||
'qr_payload' => sanitize_text_field((string) ($data['qr_payload'] ?? ($application['qr_payload'] ?? ''))),
|
||||
'provider_started_at' => $starting ? current_time('mysql', true) : ($application['provider_started_at'] ?? ''),
|
||||
'expires_at' => (string) ($data['expires_at'] ?? ($application['expires_at'] ?? '')),
|
||||
'provider_result_code' => sanitize_text_field((string) ($data['result_code'] ?? 'pending')),
|
||||
'provider_result_summary' => sanitize_text_field((string) ($data['message'] ?? '')),
|
||||
'fee_status' => $fee_status,
|
||||
];
|
||||
$updated = $this->transitions->transition($application, $next, $fields, [
|
||||
'source' => (string) $application['provider'],
|
||||
'event_type' => $starting ? 'session_started' : 'query_pending',
|
||||
'external_event_id' => (string) ($data['external_event_id'] ?? ''),
|
||||
]);
|
||||
if (is_wp_error($updated)) {
|
||||
if ($starting) {
|
||||
$this->refund(array_merge($application, ['fee_status' => $fee_status]));
|
||||
}
|
||||
return $updated;
|
||||
}
|
||||
$updated['redirect_url'] = (string) ($updated['redirect_url'] ?? '');
|
||||
$updated['qr_payload'] = (string) ($updated['qr_payload'] ?? '');
|
||||
return $this->view($updated);
|
||||
}
|
||||
private function apply_provider_result(array $application, ProviderResult $result, bool $starting, string $operation_key = '') {
|
||||
$data = $result->data;
|
||||
if ($result->decision === ProviderResult::PENDING) {
|
||||
$next = $starting ? 'provider_pending' : 'provider_processing';
|
||||
if (!$starting && (string) $application['status'] === $next) {
|
||||
return $this->view($application, null, true);
|
||||
}
|
||||
$effective_key = $this->provider_operation_key($application, $operation_key, $next, $data, $result->decision);
|
||||
$fields = [
|
||||
'provider_session_id' => sanitize_text_field((string) ($data['provider_session_id'] ?? ($application['provider_session_id'] ?? ''))),
|
||||
'provider_request_id' => sanitize_text_field((string) ($data['request_id'] ?? ($application['provider_request_id'] ?? ''))),
|
||||
'redirect_url' => esc_url_raw((string) ($data['redirect_url'] ?? ($application['redirect_url'] ?? ''))),
|
||||
'qr_payload' => sanitize_text_field((string) ($data['qr_payload'] ?? ($application['qr_payload'] ?? ''))),
|
||||
'provider_started_at' => $starting ? current_time('mysql', true) : ($application['provider_started_at'] ?? ''),
|
||||
'expires_at' => (string) ($data['expires_at'] ?? ($application['expires_at'] ?? '')),
|
||||
'provider_result_code' => sanitize_text_field((string) ($data['result_code'] ?? 'pending')),
|
||||
'provider_result_summary' => sanitize_text_field((string) ($data['message'] ?? '')),
|
||||
];
|
||||
$event = [
|
||||
'source' => (string) $application['provider'],
|
||||
'event_type' => $starting ? 'session_started' : 'query_pending',
|
||||
'external_event_id' => (string) ($data['external_event_id'] ?? ''),
|
||||
'dedupe_key' => $this->event_dedupe_key($application, $effective_key, $next),
|
||||
];
|
||||
$changed = $starting
|
||||
? $this->transition_with_charge($application, $next, $fields, $event, $effective_key)
|
||||
: $this->transition_without_charge($application, $next, $fields, $event);
|
||||
if (is_wp_error($changed)) {
|
||||
return $changed;
|
||||
}
|
||||
$updated = $changed['application'];
|
||||
$updated['redirect_url'] = (string) ($updated['redirect_url'] ?? '');
|
||||
$updated['qr_payload'] = (string) ($updated['qr_payload'] ?? '');
|
||||
return $this->view($updated, $changed['ledger']);
|
||||
}
|
||||
|
||||
$map = [
|
||||
ProviderResult::PASSED => 'verified',
|
||||
ProviderResult::FAILED => 'provider_failed',
|
||||
ProviderResult::UNSUPPORTED => 'provider_unsupported',
|
||||
ProviderResult::EXPIRED => 'provider_expired',
|
||||
];
|
||||
$next = $map[$result->decision] ?? 'provider_failed';
|
||||
$settings = SettingsRuntime::get();
|
||||
if (in_array($next, ['provider_failed', 'provider_unsupported', 'provider_expired'], true) && empty($settings['allow_manual_fallback'])) {
|
||||
$next = 'cancelled';
|
||||
}
|
||||
$updated = $this->transitions->transition($application, $next, [
|
||||
'provider_result_code' => sanitize_text_field((string) ($data['result_code'] ?? $result->decision)),
|
||||
'provider_result_summary' => sanitize_text_field((string) ($data['message'] ?? '')),
|
||||
], [
|
||||
'source' => (string) $application['provider'],
|
||||
'event_type' => 'provider_result',
|
||||
'external_event_id' => (string) ($data['external_event_id'] ?? ''),
|
||||
'result_code' => (string) ($data['result_code'] ?? $result->decision),
|
||||
]);
|
||||
return is_wp_error($updated) ? $updated : $this->view($updated);
|
||||
}
|
||||
$map = [
|
||||
ProviderResult::PASSED => 'verified',
|
||||
ProviderResult::FAILED => 'provider_failed',
|
||||
ProviderResult::UNSUPPORTED => 'provider_unsupported',
|
||||
ProviderResult::EXPIRED => 'provider_expired',
|
||||
];
|
||||
$next = $map[$result->decision] ?? 'provider_failed';
|
||||
$settings = SettingsRuntime::get();
|
||||
if (in_array($next, ['provider_failed', 'provider_unsupported', 'provider_expired'], true) && empty($settings['allow_manual_fallback'])) {
|
||||
$next = 'cancelled';
|
||||
}
|
||||
$effective_key = $this->provider_operation_key($application, $operation_key, $next, $data, $result->decision);
|
||||
$fields = [
|
||||
'provider_result_code' => sanitize_text_field((string) ($data['result_code'] ?? $result->decision)),
|
||||
'provider_result_summary' => sanitize_text_field((string) ($data['message'] ?? '')),
|
||||
];
|
||||
$event = [
|
||||
'source' => (string) $application['provider'],
|
||||
'event_type' => 'provider_result',
|
||||
'external_event_id' => (string) ($data['external_event_id'] ?? ''),
|
||||
'result_code' => (string) ($data['result_code'] ?? $result->decision),
|
||||
'dedupe_key' => $this->event_dedupe_key($application, $effective_key, $next),
|
||||
];
|
||||
$changed = $starting && $next === 'verified'
|
||||
? $this->transition_with_charge($application, $next, $fields, $event, $effective_key)
|
||||
: $this->transition_without_charge($application, $next, $fields, $event);
|
||||
return is_wp_error($changed) ? $changed : $this->view($changed['application'], $changed['ledger']);
|
||||
}
|
||||
|
||||
/** @return string|\WP_Error */
|
||||
private function charge(array $application) {
|
||||
if (in_array((string) $application['fee_status'], ['charged', 'not_required'], true)) {
|
||||
return (string) $application['fee_status'];
|
||||
}
|
||||
$fee = (float) $application['fee_amount'];
|
||||
if ($fee <= 0) {
|
||||
return 'not_required';
|
||||
}
|
||||
$result = \AKSRT\Credits\Balance::deduct(
|
||||
(int) $application['user_id'],
|
||||
$fee,
|
||||
'verify_application',
|
||||
['type' => 'identity', 'id' => (int) $application['id']],
|
||||
sprintf(__('提交%s认证申请', 'aksrt'), (string) $application['type'])
|
||||
);
|
||||
return is_wp_error($result) ? $result : 'charged';
|
||||
}
|
||||
/**
|
||||
* @param array<string,mixed> $application
|
||||
* @param array<string,mixed> $fields
|
||||
* @param array<string,mixed> $event
|
||||
* @return array{application:array<string,mixed>,ledger:?LedgerResult}|\WP_Error
|
||||
*/
|
||||
private function transition_with_charge(array $application, string $next, array $fields, array $event, string $operation_key, array $ledger_metadata = []) {
|
||||
$fee_status = (string) ($application['fee_status'] ?? '');
|
||||
if (in_array($fee_status, ['charged', 'not_required'], true)) {
|
||||
$fields['fee_status'] = $fee_status;
|
||||
return $this->transition_without_charge($application, $next, $fields, $event);
|
||||
}
|
||||
try {
|
||||
$fee = AssetAmount::balance((string) ($application['fee_amount'] ?? ''));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new \WP_Error('aksrt_identity_fee', __('认证费用配置无效', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
if ($fee->minor() <= 0) {
|
||||
$fields['fee_status'] = 'not_required';
|
||||
return $this->transition_without_charge($application, $next, $fields, $event);
|
||||
}
|
||||
|
||||
private function refund(array $application): void {
|
||||
if ((float) $application['fee_amount'] <= 0 || !in_array((string) $application['fee_status'], ['charged', 'pending'], true)) {
|
||||
return;
|
||||
}
|
||||
\AKSRT\Credits\Balance::add(
|
||||
(int) $application['user_id'],
|
||||
(float) $application['fee_amount'],
|
||||
'verify_application_refund',
|
||||
['type' => 'identity', 'id' => (int) $application['id']],
|
||||
__('认证会话创建失败退款', 'aksrt')
|
||||
);
|
||||
$this->applications->update((int) $application['id'], ['fee_status' => 'refunded']);
|
||||
}
|
||||
$application_id = (int) $application['id'];
|
||||
$user_id = (int) $application['user_id'];
|
||||
$expected_status = (string) $application['status'];
|
||||
$expected_minor = $fee->minor();
|
||||
$locked_application = null;
|
||||
$guard = function () use ($application_id, $user_id, $expected_status, $expected_minor, &$locked_application) {
|
||||
$locked_application = $this->applications->find_by_id_for_update($application_id);
|
||||
try {
|
||||
$current_minor = is_array($locked_application)
|
||||
? AssetAmount::balance((string) ($locked_application['fee_amount'] ?? ''))->minor()
|
||||
: -1;
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$current_minor = -1;
|
||||
}
|
||||
if (
|
||||
!is_array($locked_application)
|
||||
|| (int) $locked_application['user_id'] !== $user_id
|
||||
|| (string) $locked_application['status'] !== $expected_status
|
||||
|| (string) $locked_application['fee_status'] !== 'pending'
|
||||
|| $current_minor !== $expected_minor
|
||||
) {
|
||||
return new \WP_Error('aksrt_identity_fee_state', __('认证费用或申请状态已变化', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
$mutation_fields = $fields;
|
||||
$mutation_fields['fee_status'] = 'charged';
|
||||
$mutation = function () use (&$locked_application, $next, $mutation_fields, $event) {
|
||||
if (!is_array($locked_application)) {
|
||||
return new \WP_Error('aksrt_identity_fee_state', __('认证申请未锁定', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return $this->transitions->transition_locked($locked_application, $next, $mutation_fields, $event);
|
||||
};
|
||||
$ledger_metadata['identity_type'] = (string) $application['type'];
|
||||
$ledger_metadata['business_payload_hash'] ??= (string) ($application['request_payload_hash'] ?? '');
|
||||
$result = LedgerService::get_instance()->debit(
|
||||
$user_id,
|
||||
AssetType::BALANCE,
|
||||
$fee->minor(),
|
||||
$operation_key !== '' ? $operation_key : OperationKey::deterministic('identity-fee', [$application_id]),
|
||||
'verify_application',
|
||||
['type' => 'identity', 'id' => $application_id],
|
||||
sprintf(__('提交%s认证申请', 'aksrt'), (string) $application['type']),
|
||||
$user_id,
|
||||
$ledger_metadata,
|
||||
$mutation,
|
||||
$guard
|
||||
);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
$updated = $this->applications->find_by_id($application_id);
|
||||
if (!is_array($updated)) {
|
||||
return new \WP_Error('aksrt_identity_missing', __('认证申请不存在', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
if (!$result->replayed) {
|
||||
$this->transitions->after_commit(is_array($locked_application) ? $locked_application : $application, $next, $mutation_fields);
|
||||
}
|
||||
return ['application' => $updated, 'ledger' => $result];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $application
|
||||
* @param array<string,mixed> $fields
|
||||
* @param array<string,mixed> $event
|
||||
* @return array{application:array<string,mixed>,ledger:null}|\WP_Error
|
||||
*/
|
||||
private function transition_without_charge(array $application, string $next, array $fields, array $event) {
|
||||
$updated = $this->transitions->transition($application, $next, $fields, $event);
|
||||
return is_wp_error($updated) ? $updated : ['application' => $updated, 'ledger' => null];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $application @param array<string,mixed> $data */
|
||||
private function provider_operation_key(array $application, string $operation_key, string $next, array $data, string $decision): string {
|
||||
if ($operation_key !== '') {
|
||||
return $operation_key;
|
||||
}
|
||||
$external = (string) ($data['external_event_id'] ?? $data['request_id'] ?? $data['result_code'] ?? $decision);
|
||||
return OperationKey::deterministic('identity-provider', [(int) $application['id'], $next, $external]);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $application */
|
||||
private function event_dedupe_key(array $application, string $operation_key, string $next): string {
|
||||
return hash('sha256', implode('|', ['identity', (int) $application['id'], $operation_key, sanitize_key($next)]));
|
||||
}
|
||||
|
||||
private function request_payload_hash(string $type, string $person_name, string $id_number, string $company_name, string $company_code): string {
|
||||
return hash('sha256', (string) wp_json_encode([
|
||||
'type' => $type,
|
||||
'person_name' => $person_name,
|
||||
'id_number_hmac' => SettingsRuntime::hmac($id_number),
|
||||
'company_name' => $company_name,
|
||||
'company_code_hmac' => $company_code !== '' ? SettingsRuntime::hmac($company_code) : '',
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
private function length(string $value): int {
|
||||
return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user