feat(pay): card-pass redeem for VIP and balance (zibll-style)
Rewrite charge-card into typed single-use card_pass inventory with public redeem REST, user-center UI for balance/VIP, admin type-aware create/batch, and CAS mark-used fulfillment via Credits/activate_vip.
This commit is contained in:
@@ -51,21 +51,33 @@
|
||||
|
||||
<n-modal v-model:show="createVisible" preset="card" title="创建充值卡" style="max-width: 520px;">
|
||||
<n-form :model="createForm" label-placement="top">
|
||||
<n-form-item label="卡号">
|
||||
<n-input v-model:value="createForm.code" placeholder="手动填写唯一卡号" />
|
||||
<n-form-item label="卡类型">
|
||||
<n-select v-model:value="createForm.type" :options="typeOptions" />
|
||||
</n-form-item>
|
||||
<n-grid cols="2" :x-gap="12">
|
||||
<n-grid-item>
|
||||
<n-form-item label="面额">
|
||||
<n-form-item label="卡密">
|
||||
<n-input v-model:value="createForm.code" 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-form-item>
|
||||
<n-grid v-if="createForm.type === 'vip'" cols="2" :x-gap="12">
|
||||
<n-grid-item>
|
||||
<n-form-item label="VIP 等级">
|
||||
<n-select v-model:value="createForm.level" :options="levelOptions" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item label="过期时间">
|
||||
<n-date-picker v-model:value="createForm.expiresAt" type="datetime" clearable style="width: 100%;" />
|
||||
<n-form-item label="天数(0=永久)">
|
||||
<n-input-number v-model:value="createForm.days" :min="0" :max="3650" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
<n-form-item v-if="createForm.type === 'credits'" label="积分">
|
||||
<n-input-number v-model:value="createForm.points" :min="1" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="过期时间">
|
||||
<n-date-picker v-model:value="createForm.expiresAt" type="datetime" clearable style="width: 100%;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="备注">
|
||||
<n-input v-model:value="createForm.note" type="textarea" :rows="3" />
|
||||
</n-form-item>
|
||||
@@ -78,22 +90,42 @@
|
||||
|
||||
<n-modal v-model:show="batchVisible" preset="card" title="批量生成充值卡" style="max-width: 520px;">
|
||||
<n-form :model="batchForm" label-placement="top">
|
||||
<n-form-item label="卡类型">
|
||||
<n-select v-model:value="batchForm.type" :options="typeOptions" />
|
||||
</n-form-item>
|
||||
<n-grid cols="2" :x-gap="12">
|
||||
<n-grid-item>
|
||||
<n-form-item label="数量">
|
||||
<n-input-number v-model:value="batchForm.count" :min="1" :max="100" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<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-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item v-if="batchForm.type === 'credits'">
|
||||
<n-form-item label="积分">
|
||||
<n-input-number v-model:value="batchForm.points" :min="1" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
<n-grid v-if="batchForm.type === 'vip'" cols="2" :x-gap="12">
|
||||
<n-grid-item>
|
||||
<n-form-item label="VIP 等级">
|
||||
<n-select v-model:value="batchForm.level" :options="levelOptions" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item label="天数(0=永久)">
|
||||
<n-input-number v-model:value="batchForm.days" :min="0" :max="3650" style="width: 100%;" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
<n-grid cols="2" :x-gap="12">
|
||||
<n-grid-item>
|
||||
<n-form-item label="前缀">
|
||||
<n-input v-model:value="batchForm.prefix" placeholder="CARD" />
|
||||
<n-input v-model:value="batchForm.prefix" placeholder="AK" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
@@ -152,21 +184,40 @@ const filters = reactive({
|
||||
})
|
||||
|
||||
const createForm = reactive({
|
||||
type: 'balance',
|
||||
code: '',
|
||||
amount: 10,
|
||||
level: 1,
|
||||
days: 30,
|
||||
points: 100,
|
||||
expiresAt: null,
|
||||
note: '',
|
||||
})
|
||||
|
||||
const batchForm = reactive({
|
||||
type: 'balance',
|
||||
count: 20,
|
||||
amount: 10,
|
||||
prefix: 'CARD',
|
||||
level: 1,
|
||||
days: 30,
|
||||
points: 100,
|
||||
prefix: 'AK',
|
||||
length: 12,
|
||||
expiresAt: null,
|
||||
note: '',
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '余额卡', value: 'balance' },
|
||||
{ label: '会员卡', value: 'vip' },
|
||||
{ label: '积分卡', value: 'credits' },
|
||||
]
|
||||
|
||||
const levelOptions = [
|
||||
{ label: 'VIP1', value: 1 },
|
||||
{ label: 'VIP2', value: 2 },
|
||||
]
|
||||
|
||||
const pager = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
@@ -214,9 +265,26 @@ const pagination = computed(() => ({
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', key: 'id', width: 70 },
|
||||
{ title: '卡号', key: 'code', minWidth: 190, ellipsis: { tooltip: true } },
|
||||
{ title: '面额', key: 'amount', width: 90 },
|
||||
{ title: '余额', key: 'balance', width: 90 },
|
||||
{ title: '卡密', key: 'code', minWidth: 170, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '类型',
|
||||
key: 'type_label',
|
||||
width: 90,
|
||||
render: (row) => row.type_label || row.type || '-',
|
||||
},
|
||||
{
|
||||
title: '面额/权益',
|
||||
key: 'amount',
|
||||
width: 120,
|
||||
render: (row) => {
|
||||
if (row.type === 'vip') {
|
||||
const days = Number(row.days || 0)
|
||||
return `VIP${row.level || 1} / ${days > 0 ? `${days}天` : '永久'}`
|
||||
}
|
||||
if (row.type === 'credits') return `${row.points || row.amount || 0} 积分`
|
||||
return row.amount
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status_label',
|
||||
@@ -339,8 +407,13 @@ async function handleCreate() {
|
||||
await api.request('/payment/charge-cards', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
type: createForm.type,
|
||||
code: createForm.code,
|
||||
password: createForm.code,
|
||||
amount: createForm.amount,
|
||||
level: createForm.level,
|
||||
days: createForm.days,
|
||||
points: createForm.points,
|
||||
expires_at: createForm.expiresAt ? formatDateValue(createForm.expiresAt) : '',
|
||||
note: createForm.note,
|
||||
},
|
||||
@@ -363,8 +436,12 @@ async function handleBatchCreate() {
|
||||
await api.request('/payment/charge-cards/batch', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
type: batchForm.type,
|
||||
count: batchForm.count,
|
||||
amount: batchForm.amount,
|
||||
level: batchForm.level,
|
||||
days: batchForm.days,
|
||||
points: batchForm.points,
|
||||
prefix: batchForm.prefix,
|
||||
length: batchForm.length,
|
||||
expires_at: batchForm.expiresAt ? formatDateValue(batchForm.expiresAt) : '',
|
||||
|
||||
@@ -71,9 +71,9 @@ export const rechargeSchema = {
|
||||
title: '充值卡免扣',
|
||||
icon: 'fas fa-ticket-alt',
|
||||
fields: [
|
||||
{ key: 'pay_balance_pass_charge_s', label: '启用充值卡免扣', type: 'switch', default: false },
|
||||
{ key: 'pay_balance_pass_charge_desc', label: '免扣说明', type: 'textarea', default: '', dependsOn: { key: 'pay_balance_pass_charge_s', value: true } },
|
||||
{ key: 'pay_balance_pass_charge_only_password', label: '仅密码免扣', type: 'switch', default: false, dependsOn: { key: 'pay_balance_pass_charge_s', value: true } },
|
||||
{ key: 'pay_balance_pass_charge_s', label: '启用余额卡密兑换', type: 'switch', default: false },
|
||||
{ key: 'pay_balance_pass_charge_desc', label: '卡密兑换说明', type: 'textarea', default: '', dependsOn: { key: 'pay_balance_pass_charge_s', value: true } },
|
||||
{ key: 'pay_balance_pass_charge_only_password', label: '仅密码兑换', type: 'switch', default: false, dependsOn: { key: 'pay_balance_pass_charge_s', value: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ export const vipSchema = {
|
||||
pay_download_speed_vip_2: 0,
|
||||
pay_vip_pass_charge_s: false,
|
||||
pay_vip_pass_charge_desc: '',
|
||||
pay_vip_pass_charge_only_password: true,
|
||||
vip_renew: true,
|
||||
vip_renew_desc: '',
|
||||
vip_renew_price_type: 'discount',
|
||||
@@ -61,6 +62,7 @@ export const vipSchema = {
|
||||
{ key: 'pay_download_speed_vip_2', label: '二级会员下载速度(KB/s)', type: 'number', default: 0, min: 0, max: 99999, dependsOn: { key: 'pay_user_vip_2_s', value: true } },
|
||||
{ key: 'pay_vip_pass_charge_s', label: '会员卡密兑换', type: 'switch', default: false },
|
||||
{ key: 'pay_vip_pass_charge_desc', label: '卡密兑换说明', type: 'textarea', default: '', dependsOn: { key: 'pay_vip_pass_charge_s', value: true } },
|
||||
{ key: 'pay_vip_pass_charge_only_password', label: '仅密码兑换', type: 'switch', default: true, dependsOn: { key: 'pay_vip_pass_charge_s', value: true }, help: '开启后只需输入卡密;关闭则需卡号+卡密。' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -560,3 +560,62 @@
|
||||
box-shadow: none;
|
||||
border-color: var(--color-line, #e5e7eb);
|
||||
}
|
||||
|
||||
/* Card-pass redeem (balance / VIP) */
|
||||
.uc-card-pass {
|
||||
margin: 16px 0 0;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--color-line, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
.uc-card-pass__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.uc-card-pass__title i { color: #2563eb; }
|
||||
.uc-card-pass__desc {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.uc-card-pass__input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0 0 8px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.uc-card-pass__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.uc-card-pass__btn:hover { background: #1d4ed8; }
|
||||
.uc-card-pass__btn:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.uc-card-pass__tip {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: #059669;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
.uc-card-pass__tip.is-error { color: #b91c1c; }
|
||||
|
||||
|
||||
@@ -442,17 +442,84 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 卡密兑换(余额 / 会员) =====
|
||||
function initCardPass() {
|
||||
var roots = document.querySelectorAll('[data-aksrt-card-pass]');
|
||||
if (!roots || !roots.length) return;
|
||||
|
||||
Array.prototype.forEach.call(roots, function (root) {
|
||||
var tip = root.querySelector('[data-aksrt-card-tip]');
|
||||
var busy = false;
|
||||
|
||||
function setTip(text, isError) {
|
||||
if (!tip) return;
|
||||
tip.textContent = text || '';
|
||||
tip.classList.toggle('is-error', !!isError);
|
||||
}
|
||||
|
||||
var btn = root.querySelector('[data-aksrt-card-redeem]');
|
||||
if (!btn) return;
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
if (busy || btn.disabled) return;
|
||||
var type = btn.getAttribute('data-type') || root.getAttribute('data-aksrt-card-pass') || '';
|
||||
var pwInput = root.querySelector('[data-aksrt-card-password]');
|
||||
var cardInput = root.querySelector('[data-aksrt-card-no]');
|
||||
var password = pwInput ? String(pwInput.value || '').trim() : '';
|
||||
var card = cardInput ? String(cardInput.value || '').trim() : '';
|
||||
if (!password) {
|
||||
setTip('请输入卡密', true);
|
||||
if (pwInput) pwInput.focus();
|
||||
return;
|
||||
}
|
||||
if (cardInput && !card) {
|
||||
setTip('请输入卡号', true);
|
||||
cardInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
busy = true;
|
||||
btn.disabled = true;
|
||||
setTip('正在兑换…');
|
||||
|
||||
var body = { password: password, type: type };
|
||||
if (card) body.card = card;
|
||||
|
||||
api('/pay/card-pass/redeem', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (r) {
|
||||
busy = false;
|
||||
btn.disabled = false;
|
||||
if (!r.ok || (r.json && r.json.code && !r.json.success)) {
|
||||
setTip((r.json && r.json.message) || '兑换失败', true);
|
||||
return;
|
||||
}
|
||||
var data = (r.json && r.json.data) ? r.json.data : r.json;
|
||||
setTip((data && data.message) || '兑换成功,正在刷新…');
|
||||
window.location.reload();
|
||||
}).catch(function () {
|
||||
busy = false;
|
||||
btn.disabled = false;
|
||||
setTip('网络错误,请重试', true);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initSignin();
|
||||
initInvite();
|
||||
initVipShop();
|
||||
initVipPointsExchange();
|
||||
initCardPass();
|
||||
});
|
||||
} else {
|
||||
initSignin();
|
||||
initInvite();
|
||||
initVipShop();
|
||||
initVipPointsExchange();
|
||||
initCardPass();
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -180,6 +180,9 @@ if (class_exists('AKSRT_Content_Pay')) {
|
||||
if (class_exists('AKSRT_Vip_Service')) {
|
||||
AKSRT_Vip_Service::get_instance()->init();
|
||||
}
|
||||
if (class_exists('AKSRT_Charge_Card')) {
|
||||
AKSRT_Charge_Card::get_instance()->init();
|
||||
}
|
||||
// Download State & Docs Presenter are pure service singletons (no hooks)
|
||||
if (class_exists('AKSRT_Download_State')) {
|
||||
AKSRT_Download_State::get_instance();
|
||||
|
||||
@@ -334,6 +334,9 @@ class AKSRT_Admin_Payment_Controller extends AKSRT_Admin_Controller_Base {
|
||||
$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'] ?? ''),
|
||||
(string) ($item['note'] ?? ''),
|
||||
(string) ($item['used_by_user']['display_name'] ?? ''),
|
||||
(string) ($item['used_by_user']['email'] ?? ''),
|
||||
@@ -377,14 +380,19 @@ class AKSRT_Admin_Payment_Controller extends AKSRT_Admin_Controller_Base {
|
||||
}
|
||||
|
||||
$params = $this->get_json_params($request);
|
||||
$code = sanitize_text_field((string) ($params['code'] ?? ''));
|
||||
$amount = (float) ($params['amount'] ?? 0);
|
||||
|
||||
if ($code === '' || $amount <= 0) {
|
||||
return $this->error('卡号和金额不能为空');
|
||||
$type = sanitize_key((string) ($params['type'] ?? 'balance'));
|
||||
if (!in_array($type, ['balance', 'vip', 'credits'], true)) {
|
||||
$type = 'balance';
|
||||
}
|
||||
$password = sanitize_text_field((string) ($params['password'] ?? $params['code'] ?? ''));
|
||||
if ($password === '') {
|
||||
return $this->error('卡密不能为空');
|
||||
}
|
||||
|
||||
$id = AKSRT_Charge_Card::get_instance()->create($params);
|
||||
$id = AKSRT_Charge_Card::get_instance()->create(array_merge($params, [
|
||||
'type' => $type,
|
||||
'password' => $password,
|
||||
]));
|
||||
|
||||
if ($id > 0) {
|
||||
return $this->success([
|
||||
@@ -393,7 +401,7 @@ class AKSRT_Admin_Payment_Controller extends AKSRT_Admin_Controller_Base {
|
||||
], 201);
|
||||
}
|
||||
|
||||
return $this->error(__('创建失败(可能卡号已存在)', 'aksrt'));
|
||||
return $this->error(__('创建失败(可能卡密已存在或参数无效)', 'aksrt'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -405,35 +413,43 @@ class AKSRT_Admin_Payment_Controller extends AKSRT_Admin_Controller_Base {
|
||||
}
|
||||
|
||||
$params = $this->get_json_params($request);
|
||||
$amount = (float) ($params['amount'] ?? 0);
|
||||
$type = sanitize_key((string) ($params['type'] ?? 'balance'));
|
||||
if (!in_array($type, ['balance', 'vip', 'credits'], true)) {
|
||||
$type = 'balance';
|
||||
}
|
||||
$count = max(1, min(100, (int) ($params['count'] ?? 1)));
|
||||
$length = max(8, min(32, (int) ($params['length'] ?? 16)));
|
||||
$prefix = sanitize_text_field((string) ($params['prefix'] ?? ''));
|
||||
$note = sanitize_textarea_field((string) ($params['note'] ?? ''));
|
||||
$expires = sanitize_text_field((string) ($params['expires_at'] ?? ''));
|
||||
|
||||
if ($amount <= 0) {
|
||||
return $this->error('金额必须大于 0');
|
||||
$meta_fields = [
|
||||
'amount' => (float) ($params['amount'] ?? 0),
|
||||
'level' => (int) ($params['level'] ?? 1),
|
||||
'days' => (int) ($params['days'] ?? $params['vip_days'] ?? 30),
|
||||
'points' => (int) ($params['points'] ?? $params['credits'] ?? 0),
|
||||
];
|
||||
|
||||
if ($type === 'balance' && $meta_fields['amount'] <= 0) {
|
||||
return $this->error('余额卡金额必须大于 0');
|
||||
}
|
||||
if ($type === 'credits' && $meta_fields['points'] <= 0) {
|
||||
return $this->error('积分卡积分必须大于 0');
|
||||
}
|
||||
|
||||
$service = AKSRT_Charge_Card::get_instance();
|
||||
$codes = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$code = $prefix . strtoupper(wp_generate_password($length, false));
|
||||
$id = $service->create([
|
||||
'code' => $code,
|
||||
'amount' => $amount,
|
||||
'expires_at' => $expires,
|
||||
'note' => $note,
|
||||
]);
|
||||
if ($id > 0) {
|
||||
$codes[] = $code;
|
||||
}
|
||||
}
|
||||
$codes = AKSRT_Charge_Card::get_instance()->batch_create(
|
||||
$type,
|
||||
$meta_fields,
|
||||
$count,
|
||||
$length,
|
||||
$prefix,
|
||||
$expires,
|
||||
$note
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'message' => sprintf(__('已生成 %d 张充值卡', 'aksrt'), count($codes)),
|
||||
'data' => ['codes' => $codes, 'count' => count($codes)],
|
||||
'data' => ['codes' => $codes, 'count' => count($codes), 'type' => $type],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -650,18 +666,48 @@ class AKSRT_Admin_Payment_Controller extends AKSRT_Admin_Controller_Base {
|
||||
$used_by = (int) ($row['used_by'] ?? 0);
|
||||
$user = $used_by > 0 ? get_userdata($used_by) : null;
|
||||
$status = sanitize_key((string) ($row['status'] ?? 'active'));
|
||||
$type = sanitize_key((string) ($row['type'] ?? 'balance'));
|
||||
$statusMap = [
|
||||
'active' => __('未使用', 'aksrt'),
|
||||
'used' => __('已使用', 'aksrt'),
|
||||
'expired' => __('已过期', 'aksrt'),
|
||||
'disabled' => __('已禁用', 'aksrt'),
|
||||
];
|
||||
$typeMap = [
|
||||
'balance' => __('余额卡', 'aksrt'),
|
||||
'vip' => __('会员卡', 'aksrt'),
|
||||
'credits' => __('积分卡', 'aksrt'),
|
||||
];
|
||||
|
||||
$meta = [];
|
||||
if (!empty($row['meta'])) {
|
||||
if (is_array($row['meta'])) {
|
||||
$meta = $row['meta'];
|
||||
} else {
|
||||
$decoded = json_decode((string) $row['meta'], true);
|
||||
if (is_array($decoded)) {
|
||||
$meta = $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$password = (string) ($row['password'] ?? $row['code'] ?? '');
|
||||
$card_no = (string) ($row['card'] ?? '');
|
||||
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'code' => (string) ($row['code'] ?? ''),
|
||||
'code' => $password,
|
||||
'password' => $password,
|
||||
'card' => $card_no,
|
||||
'type' => $type,
|
||||
'type_label' => $typeMap[$type] ?? $type,
|
||||
'amount' => (float) ($row['amount'] ?? 0),
|
||||
'balance' => (float) ($row['balance'] ?? 0),
|
||||
'meta' => $meta,
|
||||
'level' => (int) ($meta['level'] ?? 0),
|
||||
'days' => (int) ($meta['days'] ?? 0),
|
||||
'points' => (int) ($meta['points'] ?? 0),
|
||||
'order_number' => (string) ($row['order_number'] ?? ''),
|
||||
'status' => $status,
|
||||
'status_label' => $statusMap[$status] ?? $status,
|
||||
'used_by' => $used_by,
|
||||
|
||||
@@ -86,9 +86,9 @@ class AKSRT_Admin_Schema_Payment {
|
||||
['key' => 'pay_balance_transfer_desc', 'label' => '转账说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'pay_balance_transfer_s', 'value' => true]],
|
||||
]],
|
||||
['key' => 'pass-charge', 'title' => '充值卡免扣', 'fields' => [
|
||||
['key' => 'pay_balance_pass_charge_s', 'label' => '启用充值卡免扣', 'type' => 'switch', 'default' => false],
|
||||
['key' => 'pay_balance_pass_charge_desc', 'label' => '免扣说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'pay_balance_pass_charge_s', 'value' => true]],
|
||||
['key' => 'pay_balance_pass_charge_only_password', 'label' => '仅密码免扣', 'type' => 'switch', 'default' => false, 'dependsOn' => ['key' => 'pay_balance_pass_charge_s', 'value' => true]],
|
||||
['key' => 'pay_balance_pass_charge_s', 'label' => '启用余额卡密兑换', 'type' => 'switch', 'default' => false],
|
||||
['key' => 'pay_balance_pass_charge_desc', 'label' => '卡密兑换说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'pay_balance_pass_charge_s', 'value' => true]],
|
||||
['key' => 'pay_balance_pass_charge_only_password', 'label' => '仅密码兑换', 'type' => 'switch', 'default' => false, 'dependsOn' => ['key' => 'pay_balance_pass_charge_s', 'value' => true]],
|
||||
]],
|
||||
['key' => 'exchange', 'title' => '积分兑换', 'fields' => [
|
||||
['key' => 'pay_balance_exchange_s', 'label' => '启用积分兑换', 'type' => 'switch', 'default' => false],
|
||||
@@ -127,6 +127,7 @@ class AKSRT_Admin_Schema_Payment {
|
||||
['key' => 'pay_download_speed_vip_2', 'label' => '二级会员下载速度(KB/s)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 99999, 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true]],
|
||||
['key' => 'pay_vip_pass_charge_s', 'label' => '会员卡密兑换', 'type' => 'switch', 'default' => false],
|
||||
['key' => 'pay_vip_pass_charge_desc', 'label' => '卡密兑换说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'pay_vip_pass_charge_s', 'value' => true]],
|
||||
['key' => 'pay_vip_pass_charge_only_password', 'label' => '仅密码兑换', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'pay_vip_pass_charge_s', 'value' => true]],
|
||||
]],
|
||||
['key' => 'renew', 'title' => '续费设置', 'fields' => [
|
||||
['key' => 'vip_renew', 'label' => '允许续费', 'type' => 'switch', 'default' => true],
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<?php
|
||||
/**
|
||||
* AKSRT Charge Card — 充值卡系统
|
||||
* AKSRT Charge Card — 卡密(余额 / VIP / 积分)
|
||||
*
|
||||
* 管理充值卡的创建、使用和查询。
|
||||
* zibll-style card_pass: typed inventory, single-use CAS redeem,
|
||||
* public REST for user-center, admin create/batch.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
@@ -13,10 +14,14 @@ class AKSRT_Charge_Card {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
const STATUS_ACTIVE = 'active';
|
||||
const STATUS_USED = 'used';
|
||||
const STATUS_EXPIRED = 'expired';
|
||||
const STATUS_DISABLED = 'disabled';
|
||||
public const TYPE_BALANCE = 'balance';
|
||||
public const TYPE_VIP = 'vip';
|
||||
public const TYPE_CREDITS = 'credits';
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_USED = 'used';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
public const STATUS_DISABLED = 'disabled';
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
@@ -25,37 +30,190 @@ class AKSRT_Charge_Card {
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_public_routes']);
|
||||
}
|
||||
|
||||
private function table(): string {
|
||||
return AKSRT_DB_Migrator::table('charge_card');
|
||||
}
|
||||
|
||||
public function register_public_routes(): void {
|
||||
register_rest_route('aksrt/v1', '/pay/card-pass/options', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_options'],
|
||||
'permission_callback' => [$this, 'check_rest_nonce'],
|
||||
]);
|
||||
register_rest_route('aksrt/v1', '/pay/card-pass/redeem', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_redeem'],
|
||||
'permission_callback' => [$this, 'check_logged_in_rest'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建充值卡
|
||||
* @param WP_REST_Request $request
|
||||
* @return true|WP_Error
|
||||
*/
|
||||
public function check_rest_nonce(WP_REST_Request $request) {
|
||||
$nonce = $request->get_header('x-wp-nonce');
|
||||
if (!$nonce) {
|
||||
return new WP_Error('aksrt_rest_no_nonce', __('缺少安全令牌', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if (!wp_verify_nonce($nonce, 'wp_rest')) {
|
||||
return new WP_Error('aksrt_rest_bad_nonce', __('安全令牌已过期,请刷新页面', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param WP_REST_Request $request
|
||||
* @return true|WP_Error
|
||||
*/
|
||||
public function check_logged_in_rest(WP_REST_Request $request) {
|
||||
$nonce = $this->check_rest_nonce($request);
|
||||
if (is_wp_error($nonce)) {
|
||||
return $nonce;
|
||||
}
|
||||
if (!is_user_logged_in()) {
|
||||
return new WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rest_options(WP_REST_Request $request) {
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => $this->get_public_options(),
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function rest_redeem(WP_REST_Request $request) {
|
||||
$params = $request->get_json_params();
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$password = sanitize_text_field((string) ($params['password'] ?? $params['code'] ?? ''));
|
||||
$card = sanitize_text_field((string) ($params['card'] ?? ''));
|
||||
$type = sanitize_key((string) ($params['type'] ?? ''));
|
||||
if ($type !== '' && !in_array($type, [self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS], true)) {
|
||||
$type = '';
|
||||
}
|
||||
|
||||
$result = $this->redeem_for_user(get_current_user_id(), $password, $card, $type);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function get_public_options(): array {
|
||||
$balance = ['enabled' => false, 'desc' => '', 'only_password' => true];
|
||||
$vip = ['enabled' => false, 'desc' => '', 'only_password' => true];
|
||||
|
||||
if (class_exists('AKSRT_Settings')) {
|
||||
if (method_exists('AKSRT_Settings', 'get_payment_recharge_settings')) {
|
||||
$recharge = AKSRT_Settings::get_payment_recharge_settings();
|
||||
$pc = is_array($recharge['pass_charge'] ?? null) ? $recharge['pass_charge'] : [];
|
||||
$balance = [
|
||||
'enabled' => !empty($pc['enabled']),
|
||||
'desc' => (string) ($pc['desc'] ?? ''),
|
||||
'only_password' => !isset($pc['only_password']) || !empty($pc['only_password']),
|
||||
];
|
||||
}
|
||||
if (method_exists('AKSRT_Settings', 'get_payment_vip_settings')) {
|
||||
$vip_s = AKSRT_Settings::get_payment_vip_settings();
|
||||
$benefit = is_array($vip_s['benefit'] ?? null) ? $vip_s['benefit'] : [];
|
||||
$vip = [
|
||||
'enabled' => !empty($benefit['pass_charge']),
|
||||
'desc' => (string) ($benefit['pass_charge_desc'] ?? ''),
|
||||
'only_password' => !isset($benefit['pass_charge_only_password']) || !empty($benefit['pass_charge_only_password']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'balance' => $balance,
|
||||
'vip' => $vip,
|
||||
'credits' => [
|
||||
'enabled' => false,
|
||||
'desc' => '',
|
||||
'only_password' => true,
|
||||
],
|
||||
'labels' => [
|
||||
'title' => __('卡密兑换', 'aksrt'),
|
||||
'password' => __('卡密', 'aksrt'),
|
||||
'card' => __('卡号', 'aksrt'),
|
||||
'submit_balance' => __('兑换余额', 'aksrt'),
|
||||
'submit_vip' => __('兑换会员', 'aksrt'),
|
||||
'placeholder_pw' => __('请输入卡密', 'aksrt'),
|
||||
'placeholder_card'=> __('请输入卡号', 'aksrt'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one card.
|
||||
*
|
||||
* Accepted $data keys:
|
||||
* - password|code (required unless auto)
|
||||
* - card (optional)
|
||||
* - type balance|vip|credits
|
||||
* - amount (balance)
|
||||
* - level, days (vip; days 0 = permanent)
|
||||
* - points (credits)
|
||||
* - meta (array override)
|
||||
* - expires_at, note
|
||||
*/
|
||||
public function create(array $data): int {
|
||||
global $wpdb;
|
||||
|
||||
$code = sanitize_text_field((string) ($data['code'] ?? ''));
|
||||
$amount = (float) ($data['amount'] ?? 0);
|
||||
$expires_at = sanitize_text_field((string) ($data['expires_at'] ?? ''));
|
||||
$note = sanitize_textarea_field((string) ($data['note'] ?? ''));
|
||||
$type = sanitize_key((string) ($data['type'] ?? self::TYPE_BALANCE));
|
||||
if (!in_array($type, [self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS], true)) {
|
||||
$type = self::TYPE_BALANCE;
|
||||
}
|
||||
|
||||
if ($code === '' || $amount <= 0) {
|
||||
$password = sanitize_text_field((string) ($data['password'] ?? $data['code'] ?? ''));
|
||||
if ($password === '') {
|
||||
return 0;
|
||||
}
|
||||
$card = sanitize_text_field((string) ($data['card'] ?? ''));
|
||||
|
||||
$meta = $this->normalize_meta($type, $data);
|
||||
if ($meta === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 检查 code 唯一性
|
||||
// Uniqueness: password alone (only_password mode common).
|
||||
$exists = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM {$this->table()} WHERE code = %s",
|
||||
$code
|
||||
"SELECT id FROM {$this->table()} WHERE password = %s LIMIT 1",
|
||||
$password
|
||||
));
|
||||
if ($exists) return 0;
|
||||
if ($exists) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$expires_at = sanitize_text_field((string) ($data['expires_at'] ?? ''));
|
||||
$note = sanitize_textarea_field((string) ($data['note'] ?? ''));
|
||||
$amount_col = (float) ($meta['amount'] ?? $meta['points'] ?? 0);
|
||||
|
||||
$result = $wpdb->insert($this->table(), [
|
||||
'code' => $code,
|
||||
'amount' => $amount,
|
||||
'balance' => $amount,
|
||||
'card' => $card,
|
||||
'password' => $password,
|
||||
'type' => $type,
|
||||
'amount' => $amount_col,
|
||||
'balance' => $amount_col,
|
||||
'status' => self::STATUS_ACTIVE,
|
||||
'meta' => wp_json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'order_number' => '',
|
||||
'used_by' => null,
|
||||
'used_at' => null,
|
||||
'expires_at' => $expires_at !== '' ? $expires_at : null,
|
||||
'created_at' => current_time('mysql'),
|
||||
'note' => $note,
|
||||
@@ -65,76 +223,375 @@ class AKSRT_Charge_Card {
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量生成充值卡
|
||||
* @param array<string,mixed> $meta_fields
|
||||
* @return array<int,string> passwords/codes generated
|
||||
*/
|
||||
public function batch_create(float $amount, int $count, int $length = 16, string $prefix = ''): array {
|
||||
public function batch_create(string $type, array $meta_fields, int $count, int $length = 16, string $prefix = '', string $expires_at = '', string $note = ''): array {
|
||||
$type = sanitize_key($type);
|
||||
if (!in_array($type, [self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS], true)) {
|
||||
$type = self::TYPE_BALANCE;
|
||||
}
|
||||
$count = max(1, min(500, $count));
|
||||
$length = max(8, min(32, $length));
|
||||
$prefix = sanitize_text_field($prefix);
|
||||
$codes = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$code = $prefix . strtoupper(wp_generate_password($length, false));
|
||||
$id = $this->create([
|
||||
'code' => $code,
|
||||
'amount' => $amount,
|
||||
]);
|
||||
$password = $prefix . strtoupper(wp_generate_password($length, false));
|
||||
$id = $this->create(array_merge($meta_fields, [
|
||||
'type' => $type,
|
||||
'password' => $password,
|
||||
'expires_at' => $expires_at,
|
||||
'note' => $note,
|
||||
]));
|
||||
if ($id > 0) {
|
||||
$codes[] = $code;
|
||||
$codes[] = $password;
|
||||
}
|
||||
}
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用充值卡
|
||||
* @return array{success:bool,message:string,type?:string,amount?:float,level?:int,days?:int,order_number?:string,balance?:int}|WP_Error
|
||||
*/
|
||||
public function redeem(string $code, int $user_id): array {
|
||||
public function redeem_for_user(int $user_id, string $password, string $card = '', string $expected_type = '') {
|
||||
$user_id = max(0, $user_id);
|
||||
$password = sanitize_text_field($password);
|
||||
$card = sanitize_text_field($card);
|
||||
|
||||
if ($user_id <= 0) {
|
||||
return new WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
if ($password === '') {
|
||||
return new WP_Error('aksrt_card_empty', __('请输入卡密', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
// Simple per-user throttle.
|
||||
$throttle_key = 'aksrt_card_pass_rl_' . $user_id;
|
||||
$hits = (int) get_transient($throttle_key);
|
||||
if ($hits >= 20) {
|
||||
return new WP_Error('aksrt_card_rate', __('操作过于频繁,请稍后再试', 'aksrt'), ['status' => 429]);
|
||||
}
|
||||
set_transient($throttle_key, $hits + 1, MINUTE_IN_SECONDS);
|
||||
|
||||
$row = $this->find_active($password, $card, $expected_type);
|
||||
if (!$row) {
|
||||
return new WP_Error('aksrt_card_invalid', __('卡号或卡密错误', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$type = sanitize_key((string) ($row['type'] ?? self::TYPE_BALANCE));
|
||||
if ($expected_type !== '' && $type !== $expected_type) {
|
||||
return new WP_Error('aksrt_card_type', __('当前卡密不能用于该兑换类型', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$gate = $this->assert_type_enabled($type);
|
||||
if (is_wp_error($gate)) {
|
||||
return $gate;
|
||||
}
|
||||
|
||||
// only_password: if feature requires card number and empty, fail.
|
||||
$opts = $this->get_public_options();
|
||||
$only_password = true;
|
||||
if ($type === self::TYPE_BALANCE) {
|
||||
$only_password = !empty($opts['balance']['only_password']);
|
||||
} elseif ($type === self::TYPE_VIP) {
|
||||
$only_password = !empty($opts['vip']['only_password']);
|
||||
}
|
||||
if (!$only_password && $card === '' && (string) ($row['card'] ?? '') !== '') {
|
||||
return new WP_Error('aksrt_card_need_no', __('请输入卡号', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (!$only_password && $card !== '' && (string) ($row['card'] ?? '') !== '' && (string) $row['card'] !== $card) {
|
||||
return new WP_Error('aksrt_card_invalid', __('卡号或卡密错误', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
if (!empty($row['expires_at']) && strtotime((string) $row['expires_at']) < time()) {
|
||||
global $wpdb;
|
||||
|
||||
$code = sanitize_text_field($code);
|
||||
$card = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table()} WHERE code = %s",
|
||||
$code
|
||||
), ARRAY_A);
|
||||
|
||||
if (!$card) {
|
||||
return ['success' => false, 'message' => '充值卡不存在'];
|
||||
$wpdb->update($this->table(), ['status' => self::STATUS_EXPIRED], ['id' => (int) $row['id']], ['%s'], ['%d']);
|
||||
return new WP_Error('aksrt_card_expired', __('卡密已过期', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
if ($card['status'] !== self::STATUS_ACTIVE) {
|
||||
return ['success' => false, 'message' => '充值卡已使用或已失效'];
|
||||
$meta = $this->decode_meta($row);
|
||||
$card_id = (int) $row['id'];
|
||||
|
||||
// Create audit order first (pending), then CAS mark used, then fulfill + mark paid.
|
||||
$order_number = $this->create_redeem_order($user_id, $type, $meta, $card_id, $password);
|
||||
if ($order_number === '') {
|
||||
return new WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
if ($card['expires_at'] && strtotime($card['expires_at']) < time()) {
|
||||
// 标记过期
|
||||
$wpdb->update($this->table(), ['status' => self::STATUS_EXPIRED], ['id' => (int) $card['id']]);
|
||||
return ['success' => false, 'message' => '充值卡已过期'];
|
||||
if (!$this->mark_used_cas($card_id, $user_id, $order_number)) {
|
||||
// Race lost.
|
||||
if (class_exists('AKSRT_Order')) {
|
||||
AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_CANCELLED, [
|
||||
'note' => __('卡密已被使用,订单取消', 'aksrt'),
|
||||
]);
|
||||
}
|
||||
return new WP_Error('aksrt_card_used', __('该卡密已使用', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$amount = (float) $card['balance'];
|
||||
$fulfill = $this->fulfill($user_id, $type, $meta, $order_number, $card_id);
|
||||
if (is_wp_error($fulfill)) {
|
||||
return $fulfill;
|
||||
}
|
||||
|
||||
// 更新卡状态
|
||||
$wpdb->update($this->table(), [
|
||||
'status' => self::STATUS_USED,
|
||||
'used_by' => $user_id,
|
||||
'used_at' => current_time('mysql'),
|
||||
'balance' => 0,
|
||||
], ['id' => (int) $card['id']]);
|
||||
|
||||
// 增加用户余额
|
||||
if (class_exists('AKSRT_Credits')) {
|
||||
AKSRT_Credits::add($user_id, (int) $amount, 'charge_card', [
|
||||
'ref_type' => 'charge_card',
|
||||
'ref_id' => (int) $card['id'],
|
||||
if (class_exists('AKSRT_Order')) {
|
||||
AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_PAID, [
|
||||
'payment_method' => 'card_pass',
|
||||
'payment_transaction_id' => 'card_pass_' . $card_id,
|
||||
'note' => (string) ($fulfill['message'] ?? __('卡密兑换成功', 'aksrt')),
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
$out = array_merge([
|
||||
'success' => true,
|
||||
'message' => sprintf('充值成功,已到账 %s 元', number_format($amount, 2)),
|
||||
'amount' => $amount,
|
||||
];
|
||||
'type' => $type,
|
||||
'order_number' => $order_number,
|
||||
'reload' => true,
|
||||
], $fulfill);
|
||||
|
||||
if (class_exists('AKSRT_Credits')) {
|
||||
$out['balance'] = AKSRT_Credits::balance($user_id);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取充值卡列表
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function find_active(string $password, string $card = '', string $type = ''): ?array {
|
||||
global $wpdb;
|
||||
$password = sanitize_text_field($password);
|
||||
if ($password === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sql = "SELECT * FROM {$this->table()} WHERE password = %s";
|
||||
$params = [$password];
|
||||
if ($type !== '') {
|
||||
$sql .= ' AND type = %s';
|
||||
$params[] = sanitize_key($type);
|
||||
}
|
||||
$sql .= ' LIMIT 1';
|
||||
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
if ((string) ($row['status'] ?? '') !== self::STATUS_ACTIVE) {
|
||||
return null;
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
public function mark_used_cas(int $card_id, int $user_id, string $order_number): bool {
|
||||
global $wpdb;
|
||||
if ($card_id <= 0) {
|
||||
return false;
|
||||
}
|
||||
$updated = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$this->table()} SET status = %s, used_by = %d, used_at = %s, order_number = %s, balance = 0
|
||||
WHERE id = %d AND status = %s",
|
||||
self::STATUS_USED,
|
||||
$user_id,
|
||||
current_time('mysql'),
|
||||
$order_number,
|
||||
$card_id,
|
||||
self::STATUS_ACTIVE
|
||||
));
|
||||
return $updated === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $meta
|
||||
* @return array{message:string,amount?:float,level?:int,days?:int}|WP_Error
|
||||
*/
|
||||
private function fulfill(int $user_id, string $type, array $meta, string $order_number, int $card_id) {
|
||||
if ($type === self::TYPE_BALANCE) {
|
||||
$amount = (int) round((float) ($meta['amount'] ?? 0));
|
||||
if ($amount <= 0 || !class_exists('AKSRT_Credits')) {
|
||||
return new WP_Error('aksrt_card_amount', __('当前卡密可充值金额为0', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$new = AKSRT_Credits::add($user_id, $amount, 'card_pass_balance', [
|
||||
'ref_type' => 'charge_card',
|
||||
'ref_id' => $card_id,
|
||||
'order' => $order_number,
|
||||
], sprintf(__('卡密充值 %d', 'aksrt'), $amount));
|
||||
if ($new === null || $new === false) {
|
||||
return new WP_Error('aksrt_credit_fail', __('余额入账失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return [
|
||||
'message' => sprintf(__('卡密充值成功,充值金额:%s', 'aksrt'), (string) $amount),
|
||||
'amount' => (float) $amount,
|
||||
];
|
||||
}
|
||||
|
||||
if ($type === self::TYPE_VIP) {
|
||||
$level = max(1, min(2, (int) ($meta['level'] ?? 1)));
|
||||
$days = max(0, (int) ($meta['days'] ?? 0));
|
||||
if (!class_exists('AKSRT_Vip_Service')) {
|
||||
return new WP_Error('aksrt_vip_unavailable', __('会员系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
AKSRT_Vip_Service::get_instance()->activate_vip($user_id, $level, $days);
|
||||
$msg = $days > 0
|
||||
? sprintf(
|
||||
/* translators: 1: level 2: days */
|
||||
__('卡密兑换成功:VIP%s %d 天', 'aksrt'),
|
||||
(string) $level,
|
||||
$days
|
||||
)
|
||||
: sprintf(
|
||||
/* translators: %s: level */
|
||||
__('卡密兑换成功:VIP%s 永久', 'aksrt'),
|
||||
(string) $level
|
||||
);
|
||||
return [
|
||||
'message' => $msg,
|
||||
'level' => $level,
|
||||
'days' => $days,
|
||||
];
|
||||
}
|
||||
|
||||
if ($type === self::TYPE_CREDITS) {
|
||||
$points = (int) ($meta['points'] ?? 0);
|
||||
if ($points <= 0 || !class_exists('AKSRT_Credits')) {
|
||||
return new WP_Error('aksrt_card_points', __('当前卡密可兑换积分为0', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
AKSRT_Credits::add($user_id, $points, 'card_pass_credits', [
|
||||
'ref_type' => 'charge_card',
|
||||
'ref_id' => $card_id,
|
||||
'order' => $order_number,
|
||||
], sprintf(__('卡密兑换积分 %d', 'aksrt'), $points));
|
||||
return [
|
||||
'message' => sprintf(__('卡密兑换成功,积分:%d', 'aksrt'), $points),
|
||||
'amount' => (float) $points,
|
||||
];
|
||||
}
|
||||
|
||||
return new WP_Error('aksrt_card_type', __('不支持的卡密类型', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $meta
|
||||
*/
|
||||
private function create_redeem_order(int $user_id, string $type, array $meta, int $card_id, string $password): string {
|
||||
if (!class_exists('AKSRT_Order_Repository') || !class_exists('AKSRT_Domain_Model')) {
|
||||
return 'CARD' . $card_id . '_' . time();
|
||||
}
|
||||
|
||||
$product_type = AKSRT_Domain_Model::ORDER_PRODUCT_RECHARGE;
|
||||
$product_id = $card_id;
|
||||
$amount = 0.0;
|
||||
$currency = 'CNY';
|
||||
|
||||
if ($type === self::TYPE_VIP) {
|
||||
$product_type = AKSRT_Domain_Model::ORDER_PRODUCT_VIP;
|
||||
$amount = 0.0;
|
||||
} elseif ($type === self::TYPE_BALANCE) {
|
||||
$amount = round((float) ($meta['amount'] ?? 0), 2);
|
||||
$currency = 'CNY';
|
||||
} elseif ($type === self::TYPE_CREDITS) {
|
||||
$amount = (float) ($meta['points'] ?? 0);
|
||||
$currency = 'points';
|
||||
}
|
||||
|
||||
$note = wp_json_encode([
|
||||
'card_pass' => 1,
|
||||
'card_id' => $card_id,
|
||||
'card_type' => $type,
|
||||
'meta' => $meta,
|
||||
'password' => substr(md5($password), 0, 8),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return AKSRT_Order_Repository::get_instance()->create([
|
||||
'user_id' => $user_id,
|
||||
'product_type' => $product_type,
|
||||
'product_id' => $product_id,
|
||||
'amount' => $amount,
|
||||
'currency' => $currency,
|
||||
'status' => class_exists('AKSRT_Order') ? AKSRT_Order::STATUS_PENDING : 'pending',
|
||||
'payment_method' => 'card_pass',
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true|WP_Error
|
||||
*/
|
||||
private function assert_type_enabled(string $type) {
|
||||
$opts = $this->get_public_options();
|
||||
if ($type === self::TYPE_BALANCE && empty($opts['balance']['enabled'])) {
|
||||
return new WP_Error('aksrt_card_disabled', __('余额卡密兑换未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if ($type === self::TYPE_VIP && empty($opts['vip']['enabled'])) {
|
||||
return new WP_Error('aksrt_card_disabled', __('会员卡密兑换未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if ($type === self::TYPE_CREDITS && empty($opts['credits']['enabled'])) {
|
||||
return new WP_Error('aksrt_card_disabled', __('积分卡密兑换未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $data
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function normalize_meta(string $type, array $data): ?array {
|
||||
if (is_array($data['meta'] ?? null)) {
|
||||
$raw = $data['meta'];
|
||||
} else {
|
||||
$raw = $data;
|
||||
}
|
||||
|
||||
if ($type === self::TYPE_BALANCE) {
|
||||
$amount = round((float) ($raw['amount'] ?? $data['amount'] ?? 0), 2);
|
||||
if ($amount <= 0) {
|
||||
return null;
|
||||
}
|
||||
return ['amount' => $amount];
|
||||
}
|
||||
if ($type === self::TYPE_VIP) {
|
||||
$level = max(1, min(2, (int) ($raw['level'] ?? $data['level'] ?? 1)));
|
||||
$days = max(0, (int) ($raw['days'] ?? $data['days'] ?? $data['vip_days'] ?? 0));
|
||||
return ['level' => $level, 'days' => $days];
|
||||
}
|
||||
if ($type === self::TYPE_CREDITS) {
|
||||
$points = max(0, (int) ($raw['points'] ?? $data['points'] ?? $data['credits'] ?? 0));
|
||||
if ($points <= 0) {
|
||||
return null;
|
||||
}
|
||||
return ['points' => $points];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function decode_meta(array $row): array {
|
||||
$meta = [];
|
||||
if (!empty($row['meta'])) {
|
||||
if (is_array($row['meta'])) {
|
||||
$meta = $row['meta'];
|
||||
} else {
|
||||
$decoded = json_decode((string) $row['meta'], true);
|
||||
if (is_array($decoded)) {
|
||||
$meta = $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Legacy amount-only rows.
|
||||
if ($meta === [] && isset($row['amount'])) {
|
||||
$meta = ['amount' => (float) $row['amount']];
|
||||
}
|
||||
return $meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin list helper (supports new + legacy columns).
|
||||
*
|
||||
* @return array{items:array,total:int,page:int,max_pages:int}
|
||||
*/
|
||||
public function get_list(array $args = []): array {
|
||||
global $wpdb;
|
||||
@@ -142,46 +599,47 @@ class AKSRT_Charge_Card {
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
$type = sanitize_key((string) ($args['type'] ?? ''));
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
if ($type !== '') {
|
||||
$where[] = 'type = %s';
|
||||
$params[] = $type;
|
||||
}
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
|
||||
$total = $params ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) : (int) $wpdb->get_var($count_sql);
|
||||
$total = $params
|
||||
? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params))
|
||||
: (int) $wpdb->get_var($count_sql);
|
||||
|
||||
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
$items = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
|
||||
return [
|
||||
'items' => $items ?: [],
|
||||
'total' => $total,
|
||||
'page' => floor($offset / $limit) + 1,
|
||||
'max_pages' => ceil($total / $limit),
|
||||
'page' => (int) floor($offset / $limit) + 1,
|
||||
'max_pages' => (int) ceil(max(1, $total) / $limit),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用充值卡
|
||||
*/
|
||||
public function disable(int $id): bool {
|
||||
global $wpdb;
|
||||
return (bool) $wpdb->update($this->table(), [
|
||||
'status' => self::STATUS_DISABLED,
|
||||
], ['id' => $id]);
|
||||
], ['id' => $id], ['%s'], ['%d']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function get_stats(): array {
|
||||
global $wpdb;
|
||||
@@ -206,4 +664,21 @@ class AKSRT_Charge_Card {
|
||||
'used_amount' => (float) ($row['used_amount'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy alias used by older admin code.
|
||||
*
|
||||
* @return array{success:bool,message:string,amount?:float}
|
||||
*/
|
||||
public function redeem(string $code, int $user_id): array {
|
||||
$result = $this->redeem_for_user($user_id, $code, '', self::TYPE_BALANCE);
|
||||
if (is_wp_error($result)) {
|
||||
return ['success' => false, 'message' => $result->get_error_message()];
|
||||
}
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => (string) ($result['message'] ?? __('兑换成功', 'aksrt')),
|
||||
'amount' => (float) ($result['amount'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class AKSRT_DB_Migrator {
|
||||
use AKSRT_DB_Migrator_Part_3;
|
||||
|
||||
/** Current schema version; changing it re-runs dbDelta. */
|
||||
const SCHEMA_VERSION = '1.13.0';
|
||||
const SCHEMA_VERSION = '1.14.0';
|
||||
|
||||
const OPT_KEY = 'aksrt_db_schema_version';
|
||||
|
||||
|
||||
@@ -348,22 +348,29 @@ trait AKSRT_DB_Migrator_Part_2 {
|
||||
) {$charset_collate};";
|
||||
dbDelta($sql_shipping);
|
||||
|
||||
// ── charge_card ──
|
||||
// ── charge_card (zibll-style typed card_pass) ──
|
||||
$sql_charge_card = "CREATE TABLE {$prefix}aksrt_charge_card (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
code VARCHAR(60) NOT NULL DEFAULT '',
|
||||
card VARCHAR(60) NOT NULL DEFAULT '',
|
||||
password VARCHAR(80) NOT NULL DEFAULT '',
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'balance',
|
||||
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
balance DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
meta LONGTEXT DEFAULT NULL,
|
||||
order_number VARCHAR(32) NOT NULL DEFAULT '',
|
||||
used_by BIGINT UNSIGNED DEFAULT NULL,
|
||||
used_at DATETIME DEFAULT NULL,
|
||||
expires_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
note TEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY idx_code (code),
|
||||
UNIQUE KEY idx_password (password),
|
||||
KEY idx_card (card),
|
||||
KEY idx_type (type),
|
||||
KEY idx_status (status),
|
||||
KEY idx_used_by (used_by),
|
||||
KEY idx_order_number (order_number),
|
||||
KEY idx_expires (expires_at)
|
||||
) {$charset_collate};";
|
||||
dbDelta($sql_charge_card);
|
||||
|
||||
@@ -196,6 +196,7 @@ class AKSRT_Settings_Payment_Runtime {
|
||||
'download_speed' => max(0, min(99999, (int) ($settings['pay_download_speed'] ?? 0))),
|
||||
'pass_charge' => !empty($settings['pay_vip_pass_charge_s']),
|
||||
'pass_charge_desc' => sanitize_textarea_field((string) ($settings['pay_vip_pass_charge_desc'] ?? '')),
|
||||
'pass_charge_only_password' => !isset($settings['pay_vip_pass_charge_only_password']) || !empty($settings['pay_vip_pass_charge_only_password']),
|
||||
],
|
||||
'renew' => [
|
||||
'enabled' => !isset($settings['vip_renew']) || !empty($settings['vip_renew']),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* User center credits: balance card + sign-in controls.
|
||||
* User center credits: balance card + sign-in controls + card-pass redeem.
|
||||
*
|
||||
* Expects extracted $context vars.
|
||||
*
|
||||
@@ -11,6 +11,20 @@ defined('ABSPATH') || exit;
|
||||
|
||||
$balance = (float) ($credit_balance ?? 0.0);
|
||||
$credits_enabled = !empty($credits_enabled);
|
||||
|
||||
$card_pass_balance = ['enabled' => false, 'desc' => '', 'only_password' => true];
|
||||
if (class_exists('AKSRT_Charge_Card')) {
|
||||
$opts = AKSRT_Charge_Card::get_instance()->get_public_options();
|
||||
$card_pass_balance = is_array($opts['balance'] ?? null) ? $opts['balance'] : $card_pass_balance;
|
||||
} elseif (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_payment_recharge_settings')) {
|
||||
$recharge = AKSRT_Settings::get_payment_recharge_settings();
|
||||
$pc = is_array($recharge['pass_charge'] ?? null) ? $recharge['pass_charge'] : [];
|
||||
$card_pass_balance = [
|
||||
'enabled' => !empty($pc['enabled']),
|
||||
'desc' => (string) ($pc['desc'] ?? ''),
|
||||
'only_password' => !isset($pc['only_password']) || !empty($pc['only_password']),
|
||||
];
|
||||
}
|
||||
?>
|
||||
<div class="uc-balance-card">
|
||||
<div class="uc-balance-card__bg"></div>
|
||||
@@ -34,3 +48,23 @@ $credits_enabled = !empty($credits_enabled);
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($card_pass_balance['enabled'])) : ?>
|
||||
<div class="uc-card-pass" data-aksrt-card-pass="balance">
|
||||
<div class="uc-card-pass__title">
|
||||
<i class="fas fa-ticket-alt" aria-hidden="true"></i>
|
||||
<span><?php esc_html_e('卡密兑换余额', 'aksrt'); ?></span>
|
||||
</div>
|
||||
<?php if (!empty($card_pass_balance['desc'])) : ?>
|
||||
<p class="uc-card-pass__desc"><?php echo esc_html((string) $card_pass_balance['desc']); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (empty($card_pass_balance['only_password'])) : ?>
|
||||
<input type="text" class="uc-card-pass__input" name="aksrt_card_no" data-aksrt-card-no autocomplete="off" placeholder="<?php esc_attr_e('请输入卡号', 'aksrt'); ?>">
|
||||
<?php endif; ?>
|
||||
<input type="text" class="uc-card-pass__input" name="aksrt_card_password" data-aksrt-card-password autocomplete="off" placeholder="<?php esc_attr_e('请输入卡密', 'aksrt'); ?>">
|
||||
<button type="button" class="uc-card-pass__btn" data-aksrt-card-redeem data-type="balance">
|
||||
<?php esc_html_e('兑换余额', 'aksrt'); ?>
|
||||
</button>
|
||||
<p class="uc-card-pass__tip" data-aksrt-card-tip></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -70,6 +70,18 @@ if ($vip_level > 0) {
|
||||
$shop_title = __('续费 / 升级会员', 'aksrt');
|
||||
}
|
||||
$points_pkgs = is_array($points_exchange['packages'] ?? null) ? $points_exchange['packages'] : [];
|
||||
|
||||
$card_pass_vip = ['enabled' => false, 'desc' => '', 'only_password' => true];
|
||||
if (class_exists('AKSRT_Charge_Card')) {
|
||||
$card_opts = AKSRT_Charge_Card::get_instance()->get_public_options();
|
||||
$card_pass_vip = is_array($card_opts['vip'] ?? null) ? $card_opts['vip'] : $card_pass_vip;
|
||||
} elseif (!empty($vip_settings['benefit']['pass_charge'])) {
|
||||
$card_pass_vip = [
|
||||
'enabled' => true,
|
||||
'desc' => (string) ($vip_settings['benefit']['pass_charge_desc'] ?? ''),
|
||||
'only_password' => !isset($vip_settings['benefit']['pass_charge_only_password']) || !empty($vip_settings['benefit']['pass_charge_only_password']),
|
||||
];
|
||||
}
|
||||
?>
|
||||
<div class="uc-vip-card<?php echo $is_vip_user ? ' uc-vip-card--active' : ''; ?>" data-aksrt-vip-panel>
|
||||
<div class="uc-vip-card__bg"></div>
|
||||
@@ -241,3 +253,23 @@ $points_pkgs = is_array($points_exchange['packages'] ?? null) ? $points_exchange
|
||||
<?php elseif (empty($packages) && !empty($vip_settings['help_text'])) : ?>
|
||||
<p class="uc-vip-shop__empty"><?php echo esc_html((string) $vip_settings['help_text']); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($card_pass_vip['enabled'])) : ?>
|
||||
<div class="uc-card-pass" data-aksrt-card-pass="vip">
|
||||
<div class="uc-card-pass__title">
|
||||
<i class="fas fa-ticket-alt" aria-hidden="true"></i>
|
||||
<span><?php esc_html_e('卡密兑换会员', 'aksrt'); ?></span>
|
||||
</div>
|
||||
<?php if (!empty($card_pass_vip['desc'])) : ?>
|
||||
<p class="uc-card-pass__desc"><?php echo esc_html((string) $card_pass_vip['desc']); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (empty($card_pass_vip['only_password'])) : ?>
|
||||
<input type="text" class="uc-card-pass__input" name="aksrt_card_no" data-aksrt-card-no autocomplete="off" placeholder="<?php esc_attr_e('请输入卡号', 'aksrt'); ?>">
|
||||
<?php endif; ?>
|
||||
<input type="text" class="uc-card-pass__input" name="aksrt_card_password" data-aksrt-card-password autocomplete="off" placeholder="<?php esc_attr_e('请输入卡密', 'aksrt'); ?>">
|
||||
<button type="button" class="uc-card-pass__btn" data-aksrt-card-redeem data-type="vip">
|
||||
<?php esc_html_e('兑换会员', 'aksrt'); ?>
|
||||
</button>
|
||||
<p class="uc-card-pass__tip" data-aksrt-card-tip></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
Reference in New Issue
Block a user