Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
97f14b7a
Unverified
Commit
97f14b7a
authored
Apr 11, 2026
by
Wesley Liddick
Committed by
GitHub
Apr 11, 2026
Browse files
Merge pull request #1572 from touwaeriol/feat/payment-system-v2
feat(payment): add complete payment system with multi-provider support
parents
1ef3782d
6793503e
Changes
174
Hide whitespace changes
Inline
Side-by-side
backend/internal/service/payment_stats.go
0 → 100644
View file @
97f14b7a
package
service
import
(
"context"
"encoding/json"
"log/slog"
"math"
"sort"
"strconv"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
)
// --- Dashboard & Analytics ---
func
(
s
*
PaymentService
)
GetDashboardStats
(
ctx
context
.
Context
,
days
int
)
(
*
DashboardStats
,
error
)
{
if
days
<=
0
{
days
=
30
}
now
:=
time
.
Now
()
since
:=
now
.
AddDate
(
0
,
0
,
-
days
)
todayStart
:=
time
.
Date
(
now
.
Year
(),
now
.
Month
(),
now
.
Day
(),
0
,
0
,
0
,
0
,
now
.
Location
())
paidStatuses
:=
[]
string
{
OrderStatusCompleted
,
OrderStatusPaid
,
OrderStatusRecharging
}
orders
,
err
:=
s
.
entClient
.
PaymentOrder
.
Query
()
.
Where
(
paymentorder
.
StatusIn
(
paidStatuses
...
),
paymentorder
.
PaidAtGTE
(
since
),
)
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
st
:=
&
DashboardStats
{}
computeBasicStats
(
st
,
orders
,
todayStart
)
st
.
PendingOrders
,
err
=
s
.
entClient
.
PaymentOrder
.
Query
()
.
Where
(
paymentorder
.
StatusEQ
(
OrderStatusPending
))
.
Count
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
st
.
DailySeries
=
buildDailySeries
(
orders
,
since
,
days
)
st
.
PaymentMethods
=
buildMethodDistribution
(
orders
)
st
.
TopUsers
=
buildTopUsers
(
orders
)
return
st
,
nil
}
func
computeBasicStats
(
st
*
DashboardStats
,
orders
[]
*
dbent
.
PaymentOrder
,
todayStart
time
.
Time
)
{
var
totalAmount
,
todayAmount
float64
var
todayCount
int
for
_
,
o
:=
range
orders
{
totalAmount
+=
o
.
PayAmount
if
o
.
PaidAt
!=
nil
&&
!
o
.
PaidAt
.
Before
(
todayStart
)
{
todayAmount
+=
o
.
PayAmount
todayCount
++
}
}
st
.
TotalAmount
=
math
.
Round
(
totalAmount
*
100
)
/
100
st
.
TodayAmount
=
math
.
Round
(
todayAmount
*
100
)
/
100
st
.
TotalCount
=
len
(
orders
)
st
.
TodayCount
=
todayCount
if
st
.
TotalCount
>
0
{
st
.
AvgAmount
=
math
.
Round
(
totalAmount
/
float64
(
st
.
TotalCount
)
*
100
)
/
100
}
}
func
buildDailySeries
(
orders
[]
*
dbent
.
PaymentOrder
,
since
time
.
Time
,
days
int
)
[]
DailyStats
{
dailyMap
:=
make
(
map
[
string
]
*
DailyStats
)
for
_
,
o
:=
range
orders
{
if
o
.
PaidAt
==
nil
{
continue
}
date
:=
o
.
PaidAt
.
Format
(
"2006-01-02"
)
ds
,
ok
:=
dailyMap
[
date
]
if
!
ok
{
ds
=
&
DailyStats
{
Date
:
date
}
dailyMap
[
date
]
=
ds
}
ds
.
Amount
+=
o
.
PayAmount
ds
.
Count
++
}
series
:=
make
([]
DailyStats
,
0
,
days
)
for
i
:=
0
;
i
<
days
;
i
++
{
date
:=
since
.
AddDate
(
0
,
0
,
i
+
1
)
.
Format
(
"2006-01-02"
)
if
ds
,
ok
:=
dailyMap
[
date
];
ok
{
ds
.
Amount
=
math
.
Round
(
ds
.
Amount
*
100
)
/
100
series
=
append
(
series
,
*
ds
)
}
else
{
series
=
append
(
series
,
DailyStats
{
Date
:
date
})
}
}
return
series
}
func
buildMethodDistribution
(
orders
[]
*
dbent
.
PaymentOrder
)
[]
PaymentMethodStat
{
methodMap
:=
make
(
map
[
string
]
*
PaymentMethodStat
)
for
_
,
o
:=
range
orders
{
ms
,
ok
:=
methodMap
[
o
.
PaymentType
]
if
!
ok
{
ms
=
&
PaymentMethodStat
{
Type
:
o
.
PaymentType
}
methodMap
[
o
.
PaymentType
]
=
ms
}
ms
.
Amount
+=
o
.
PayAmount
ms
.
Count
++
}
methods
:=
make
([]
PaymentMethodStat
,
0
,
len
(
methodMap
))
for
_
,
ms
:=
range
methodMap
{
ms
.
Amount
=
math
.
Round
(
ms
.
Amount
*
100
)
/
100
methods
=
append
(
methods
,
*
ms
)
}
return
methods
}
func
buildTopUsers
(
orders
[]
*
dbent
.
PaymentOrder
)
[]
TopUserStat
{
userMap
:=
make
(
map
[
int64
]
*
TopUserStat
)
for
_
,
o
:=
range
orders
{
us
,
ok
:=
userMap
[
o
.
UserID
]
if
!
ok
{
us
=
&
TopUserStat
{
UserID
:
o
.
UserID
,
Email
:
o
.
UserEmail
}
userMap
[
o
.
UserID
]
=
us
}
us
.
Amount
+=
o
.
PayAmount
}
userList
:=
make
([]
*
TopUserStat
,
0
,
len
(
userMap
))
for
_
,
us
:=
range
userMap
{
us
.
Amount
=
math
.
Round
(
us
.
Amount
*
100
)
/
100
userList
=
append
(
userList
,
us
)
}
sort
.
Slice
(
userList
,
func
(
i
,
j
int
)
bool
{
return
userList
[
i
]
.
Amount
>
userList
[
j
]
.
Amount
})
limit
:=
topUsersLimit
if
len
(
userList
)
<
limit
{
limit
=
len
(
userList
)
}
result
:=
make
([]
TopUserStat
,
0
,
limit
)
for
i
:=
0
;
i
<
limit
;
i
++
{
result
=
append
(
result
,
*
userList
[
i
])
}
return
result
}
// --- Audit Logs ---
func
(
s
*
PaymentService
)
writeAuditLog
(
ctx
context
.
Context
,
oid
int64
,
action
,
op
string
,
detail
map
[
string
]
any
)
{
dj
,
_
:=
json
.
Marshal
(
detail
)
_
,
err
:=
s
.
entClient
.
PaymentAuditLog
.
Create
()
.
SetOrderID
(
strconv
.
FormatInt
(
oid
,
10
))
.
SetAction
(
action
)
.
SetDetail
(
string
(
dj
))
.
SetOperator
(
op
)
.
Save
(
ctx
)
if
err
!=
nil
{
slog
.
Error
(
"audit log failed"
,
"orderID"
,
oid
,
"action"
,
action
,
"error"
,
err
)
}
}
func
(
s
*
PaymentService
)
GetOrderAuditLogs
(
ctx
context
.
Context
,
oid
int64
)
([]
*
dbent
.
PaymentAuditLog
,
error
)
{
return
s
.
entClient
.
PaymentAuditLog
.
Query
()
.
Where
(
paymentauditlog
.
OrderIDEQ
(
strconv
.
FormatInt
(
oid
,
10
)))
.
Order
(
paymentauditlog
.
ByCreatedAt
())
.
All
(
ctx
)
}
backend/internal/service/setting_service.go
View file @
97f14b7a
...
...
@@ -170,6 +170,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyBackendModeEnabled
,
SettingKeyOIDCConnectEnabled
,
SettingKeyOIDCConnectProviderName
,
SettingPaymentEnabled
,
}
settings
,
err
:=
s
.
settingRepo
.
GetMultiple
(
ctx
,
keys
)
...
...
@@ -236,6 +237,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
BackendModeEnabled
:
settings
[
SettingKeyBackendModeEnabled
]
==
"true"
,
OIDCOAuthEnabled
:
oidcEnabled
,
OIDCOAuthProviderName
:
oidcProviderName
,
PaymentEnabled
:
settings
[
SettingPaymentEnabled
]
==
"true"
,
},
nil
}
...
...
@@ -287,6 +289,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
BackendModeEnabled
bool
`json:"backend_mode_enabled"`
OIDCOAuthEnabled
bool
`json:"oidc_oauth_enabled"`
OIDCOAuthProviderName
string
`json:"oidc_oauth_provider_name"`
PaymentEnabled
bool
`json:"payment_enabled"`
Version
string
`json:"version,omitempty"`
}{
RegistrationEnabled
:
settings
.
RegistrationEnabled
,
...
...
@@ -316,6 +319,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
BackendModeEnabled
:
settings
.
BackendModeEnabled
,
OIDCOAuthEnabled
:
settings
.
OIDCOAuthEnabled
,
OIDCOAuthProviderName
:
settings
.
OIDCOAuthProviderName
,
PaymentEnabled
:
settings
.
PaymentEnabled
,
Version
:
s
.
version
,
},
nil
}
...
...
backend/internal/service/settings_view.go
View file @
97f14b7a
...
...
@@ -143,6 +143,7 @@ type PublicSettings struct {
BackendModeEnabled
bool
OIDCOAuthEnabled
bool
OIDCOAuthProviderName
string
PaymentEnabled
bool
Version
string
}
...
...
backend/internal/service/wire.go
View file @
97f14b7a
...
...
@@ -5,7 +5,9 @@ import (
"database/sql"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/google/wire"
"github.com/redis/go-redis/v9"
...
...
@@ -460,4 +462,20 @@ var ProviderSet = wire.NewSet(
NewGroupCapacityService
,
NewChannelService
,
NewModelPricingResolver
,
ProvidePaymentConfigService
,
NewPaymentService
,
ProvidePaymentOrderExpiryService
,
)
// ProvidePaymentConfigService wraps NewPaymentConfigService to accept the named
// payment.EncryptionKey type instead of raw []byte, avoiding Wire ambiguity.
func
ProvidePaymentConfigService
(
entClient
*
dbent
.
Client
,
settingRepo
SettingRepository
,
key
payment
.
EncryptionKey
)
*
PaymentConfigService
{
return
NewPaymentConfigService
(
entClient
,
settingRepo
,
[]
byte
(
key
))
}
// ProvidePaymentOrderExpiryService creates and starts PaymentOrderExpiryService.
func
ProvidePaymentOrderExpiryService
(
paymentSvc
*
PaymentService
)
*
PaymentOrderExpiryService
{
svc
:=
NewPaymentOrderExpiryService
(
paymentSvc
,
60
*
time
.
Second
)
svc
.
Start
()
return
svc
}
backend/migrations/092_payment_orders.sql
0 → 100644
View file @
97f14b7a
CREATE
TABLE
IF
NOT
EXISTS
payment_orders
(
id
BIGSERIAL
PRIMARY
KEY
,
user_id
BIGINT
NOT
NULL
,
user_email
VARCHAR
(
255
)
NOT
NULL
DEFAULT
''
,
user_name
VARCHAR
(
100
)
NOT
NULL
DEFAULT
''
,
user_notes
TEXT
,
amount
DECIMAL
(
20
,
2
)
NOT
NULL
,
pay_amount
DECIMAL
(
20
,
2
)
NOT
NULL
,
fee_rate
DECIMAL
(
10
,
4
)
NOT
NULL
DEFAULT
0
,
recharge_code
VARCHAR
(
64
)
NOT
NULL
DEFAULT
''
,
payment_type
VARCHAR
(
30
)
NOT
NULL
DEFAULT
''
,
payment_trade_no
VARCHAR
(
128
)
NOT
NULL
DEFAULT
''
,
pay_url
TEXT
,
qr_code
TEXT
,
qr_code_img
TEXT
,
order_type
VARCHAR
(
20
)
NOT
NULL
DEFAULT
'balance'
,
plan_id
BIGINT
,
subscription_group_id
BIGINT
,
subscription_days
INT
,
provider_instance_id
VARCHAR
(
64
),
status
VARCHAR
(
30
)
NOT
NULL
DEFAULT
'PENDING'
,
refund_amount
DECIMAL
(
20
,
2
)
NOT
NULL
DEFAULT
0
,
refund_reason
TEXT
,
refund_at
TIMESTAMPTZ
,
force_refund
BOOLEAN
NOT
NULL
DEFAULT
FALSE
,
refund_requested_at
TIMESTAMPTZ
,
refund_request_reason
TEXT
,
refund_requested_by
VARCHAR
(
20
),
expires_at
TIMESTAMPTZ
NOT
NULL
,
paid_at
TIMESTAMPTZ
,
completed_at
TIMESTAMPTZ
,
failed_at
TIMESTAMPTZ
,
failed_reason
TEXT
,
client_ip
VARCHAR
(
50
)
NOT
NULL
DEFAULT
''
,
src_host
VARCHAR
(
255
)
NOT
NULL
DEFAULT
''
,
src_url
TEXT
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
updated_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
-- Indexes
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_orders_user_id
ON
payment_orders
(
user_id
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_orders_status
ON
payment_orders
(
status
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_orders_expires_at
ON
payment_orders
(
expires_at
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_orders_created_at
ON
payment_orders
(
created_at
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_orders_paid_at
ON
payment_orders
(
paid_at
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_orders_type_paid
ON
payment_orders
(
payment_type
,
paid_at
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_orders_order_type
ON
payment_orders
(
order_type
);
backend/migrations/093_payment_audit_logs.sql
0 → 100644
View file @
97f14b7a
CREATE
TABLE
IF
NOT
EXISTS
payment_audit_logs
(
id
BIGSERIAL
PRIMARY
KEY
,
order_id
VARCHAR
(
64
)
NOT
NULL
,
action
VARCHAR
(
50
)
NOT
NULL
,
detail
TEXT
NOT
NULL
DEFAULT
''
,
operator
VARCHAR
(
100
)
NOT
NULL
DEFAULT
'system'
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_audit_logs_order_id
ON
payment_audit_logs
(
order_id
);
backend/migrations/094_removed_payment_channels.sql
0 → 100644
View file @
97f14b7a
-- Migration 092: payment_channels table was removed before release.
-- This file is a no-op placeholder to maintain migration numbering continuity.
-- The payment system now uses the existing channels table (migration 081).
SELECT
1
;
backend/migrations/095_subscription_plans.sql
0 → 100644
View file @
97f14b7a
CREATE
TABLE
IF
NOT
EXISTS
subscription_plans
(
id
BIGSERIAL
PRIMARY
KEY
,
group_id
BIGINT
NOT
NULL
,
name
VARCHAR
(
100
)
NOT
NULL
,
description
TEXT
NOT
NULL
DEFAULT
''
,
price
DECIMAL
(
20
,
2
)
NOT
NULL
,
original_price
DECIMAL
(
20
,
2
),
validity_days
INT
NOT
NULL
DEFAULT
30
,
validity_unit
VARCHAR
(
10
)
NOT
NULL
DEFAULT
'day'
,
features
TEXT
NOT
NULL
DEFAULT
''
,
product_name
VARCHAR
(
100
)
NOT
NULL
DEFAULT
''
,
for_sale
BOOLEAN
NOT
NULL
DEFAULT
TRUE
,
sort_order
INT
NOT
NULL
DEFAULT
0
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
updated_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
CREATE
INDEX
IF
NOT
EXISTS
idx_subscription_plans_group_id
ON
subscription_plans
(
group_id
);
CREATE
INDEX
IF
NOT
EXISTS
idx_subscription_plans_for_sale
ON
subscription_plans
(
for_sale
);
backend/migrations/096_payment_provider_instances.sql
0 → 100644
View file @
97f14b7a
CREATE
TABLE
IF
NOT
EXISTS
payment_provider_instances
(
id
BIGSERIAL
PRIMARY
KEY
,
provider_key
VARCHAR
(
30
)
NOT
NULL
,
name
VARCHAR
(
100
)
NOT
NULL
DEFAULT
''
,
config
TEXT
NOT
NULL
,
supported_types
VARCHAR
(
200
)
NOT
NULL
DEFAULT
''
,
enabled
BOOLEAN
NOT
NULL
DEFAULT
TRUE
,
sort_order
INT
NOT
NULL
DEFAULT
0
,
limits
TEXT
NOT
NULL
DEFAULT
''
,
refund_enabled
BOOLEAN
NOT
NULL
DEFAULT
FALSE
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
updated_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_provider_instances_provider_key
ON
payment_provider_instances
(
provider_key
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_provider_instances_enabled
ON
payment_provider_instances
(
enabled
);
backend/migrations/098_migrate_purchase_subscription_to_custom_menu.sql
0 → 100644
View file @
97f14b7a
-- 096_migrate_purchase_subscription_to_custom_menu.sql
--
-- Migrates the legacy purchase_subscription_url setting into custom_menu_items.
-- After migration, purchase_subscription_enabled is set to "false" and
-- purchase_subscription_url is cleared.
--
-- Idempotent: skips if custom_menu_items already contains
-- "migrated_purchase_subscription".
DO
$$
DECLARE
v_enabled
text
;
v_url
text
;
v_raw
text
;
v_items
jsonb
;
v_new_item
jsonb
;
BEGIN
-- Read legacy settings
SELECT
value
INTO
v_enabled
FROM
settings
WHERE
key
=
'purchase_subscription_enabled'
;
SELECT
value
INTO
v_url
FROM
settings
WHERE
key
=
'purchase_subscription_url'
;
-- Skip if not enabled or URL is empty
IF
COALESCE
(
v_enabled
,
''
)
<>
'true'
OR
COALESCE
(
TRIM
(
v_url
),
''
)
=
''
THEN
RETURN
;
END
IF
;
-- Read current custom_menu_items
SELECT
value
INTO
v_raw
FROM
settings
WHERE
key
=
'custom_menu_items'
;
IF
COALESCE
(
v_raw
,
''
)
=
''
OR
v_raw
=
'null'
THEN
v_items
:
=
'[]'
::
jsonb
;
ELSE
v_items
:
=
v_raw
::
jsonb
;
END
IF
;
-- Skip if already migrated (item with id "migrated_purchase_subscription" exists)
IF
EXISTS
(
SELECT
1
FROM
jsonb_array_elements
(
v_items
)
elem
WHERE
elem
->>
'id'
=
'migrated_purchase_subscription'
)
THEN
RETURN
;
END
IF
;
-- Build the new menu item
v_new_item
:
=
jsonb_build_object
(
'id'
,
'migrated_purchase_subscription'
,
'label'
,
'Purchase'
,
'icon_svg'
,
''
,
'url'
,
TRIM
(
v_url
),
'visibility'
,
'user'
,
'sort_order'
,
100
);
-- Append to array
v_items
:
=
v_items
||
jsonb_build_array
(
v_new_item
);
-- Upsert custom_menu_items
INSERT
INTO
settings
(
key
,
value
)
VALUES
(
'custom_menu_items'
,
v_items
::
text
)
ON
CONFLICT
(
key
)
DO
UPDATE
SET
value
=
EXCLUDED
.
value
;
-- Clear legacy settings
UPDATE
settings
SET
value
=
'false'
WHERE
key
=
'purchase_subscription_enabled'
;
UPDATE
settings
SET
value
=
''
WHERE
key
=
'purchase_subscription_url'
;
RAISE
NOTICE
'[migration-096] Migrated purchase_subscription_url (%) to custom_menu_items'
,
v_url
;
END
$$
;
backend/migrations/099_fix_migrated_purchase_menu_label_icon.sql
0 → 100644
View file @
97f14b7a
-- 097_fix_migrated_purchase_menu_label_icon.sql
--
-- Fixes the custom menu item created by migration 096: updates the label
-- from hardcoded English "Purchase" to "充值/订阅", and sets the icon_svg
-- to a credit-card SVG matching the sidebar CreditCardIcon.
--
-- Idempotent: only modifies items where id = 'migrated_purchase_subscription'.
DO
$$
DECLARE
v_raw
text
;
v_items
jsonb
;
v_idx
int
;
v_icon
text
;
v_elem
jsonb
;
v_i
int
:
=
0
;
BEGIN
SELECT
value
INTO
v_raw
FROM
settings
WHERE
key
=
'custom_menu_items'
;
IF
COALESCE
(
v_raw
,
''
)
=
''
OR
v_raw
=
'null'
THEN
RETURN
;
END
IF
;
v_items
:
=
v_raw
::
jsonb
;
-- Find the index of the migrated item by iterating the array
v_idx
:
=
NULL
;
FOR
v_elem
IN
SELECT
jsonb_array_elements
(
v_items
)
LOOP
IF
v_elem
->>
'id'
=
'migrated_purchase_subscription'
THEN
v_idx
:
=
v_i
;
EXIT
;
END
IF
;
v_i
:
=
v_i
+
1
;
END
LOOP
;
IF
v_idx
IS
NULL
THEN
RETURN
;
-- item not found, nothing to fix
END
IF
;
-- Credit card SVG (Heroicons outline, matches CreditCardIcon in AppSidebar)
v_icon
:
=
'<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"/></svg>'
;
-- Update label and icon_svg
v_items
:
=
jsonb_set
(
v_items
,
ARRAY
[
v_idx
::
text
,
'label'
],
'"充值/订阅"'
::
jsonb
);
v_items
:
=
jsonb_set
(
v_items
,
ARRAY
[
v_idx
::
text
,
'icon_svg'
],
to_jsonb
(
v_icon
));
UPDATE
settings
SET
value
=
v_items
::
text
WHERE
key
=
'custom_menu_items'
;
RAISE
NOTICE
'[migration-097] Fixed migrated_purchase_subscription: label=充值/订阅, icon=CreditCard SVG'
;
END
$$
;
backend/migrations/100_remove_easypay_from_enabled_payment_types.sql
0 → 100644
View file @
97f14b7a
-- 098_remove_easypay_from_enabled_payment_types.sql
--
-- Removes "easypay" from ENABLED_PAYMENT_TYPES setting.
-- "easypay" is a provider key, not a payment type. Valid payment types
-- are: alipay, wxpay, alipay_direct, wxpay_direct, stripe.
--
-- Idempotent: safe to run multiple times.
UPDATE
settings
SET
value
=
array_to_string
(
array_remove
(
string_to_array
(
value
,
','
),
'easypay'
),
','
)
WHERE
key
=
'ENABLED_PAYMENT_TYPES'
AND
value
LIKE
'%easypay%'
;
backend/migrations/101_add_payment_mode.sql
0 → 100644
View file @
97f14b7a
-- Add payment_mode field to payment_provider_instances
-- Values: 'redirect' (hosted page redirect), 'api' (API call for QR/payurl), '' (default/N/A)
ALTER
TABLE
payment_provider_instances
ADD
COLUMN
IF
NOT
EXISTS
payment_mode
VARCHAR
(
20
)
NOT
NULL
DEFAULT
''
;
-- Migrate existing data: easypay instances with 'easypay' in supported_types → redirect mode
-- Remove 'easypay' from supported_types and set payment_mode = 'redirect'
UPDATE
payment_provider_instances
SET
payment_mode
=
'redirect'
,
supported_types
=
TRIM
(
BOTH
','
FROM
REPLACE
(
REPLACE
(
REPLACE
(
supported_types
,
'easypay,'
,
''
),
',easypay'
,
''
),
'easypay'
,
''
))
WHERE
provider_key
=
'easypay'
AND
supported_types
LIKE
'%easypay%'
;
-- EasyPay instances without 'easypay' in supported_types → api mode
UPDATE
payment_provider_instances
SET
payment_mode
=
'api'
WHERE
provider_key
=
'easypay'
AND
payment_mode
=
''
;
backend/migrations/102_add_out_trade_no_to_payment_orders.sql
0 → 100644
View file @
97f14b7a
-- 100_add_out_trade_no_to_payment_orders.sql
-- Adds out_trade_no column for external order ID used with payment providers.
-- Allows webhook handlers to look up orders by external ID instead of embedding DB ID.
ALTER
TABLE
payment_orders
ADD
COLUMN
IF
NOT
EXISTS
out_trade_no
VARCHAR
(
64
)
NOT
NULL
DEFAULT
''
;
CREATE
INDEX
IF
NOT
EXISTS
paymentorder_out_trade_no
ON
payment_orders
(
out_trade_no
);
frontend/package.json
View file @
97f14b7a
...
...
@@ -16,6 +16,7 @@
},
"dependencies"
:
{
"@lobehub/icons"
:
"^4.0.2"
,
"@stripe/stripe-js"
:
"^9.0.1"
,
"@tanstack/vue-virtual"
:
"^3.13.23"
,
"@vueuse/core"
:
"^10.7.0"
,
"axios"
:
"^1.15.0"
,
...
...
frontend/pnpm-lock.yaml
View file @
97f14b7a
...
...
@@ -11,6 +11,9 @@ importers:
'
@lobehub/icons'
:
specifier
:
^4.0.2
version
:
4.0.2(@lobehub/ui@4.9.2)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'
@stripe/stripe-js'
:
specifier
:
^9.0.1
version
:
9.0.1
'
@tanstack/vue-virtual'
:
specifier
:
^3.13.23
version
:
3.13.23(vue@3.5.26(typescript@5.6.3))
...
...
@@ -1395,6 +1398,10 @@ packages:
peerDependencies
:
react
:
'
>=
16.3.0'
'
@stripe/stripe-js@9.0.1'
:
resolution
:
{
integrity
:
sha512-un0URSosrW7wNr7xZ5iI2mC9mdeXZ3KERoVlA2RdmeLXYxHUPXq0yHzir2n/MtyXXEdSaELtz4WXGS6dzPEeKA==
}
engines
:
{
node
:
'
>=12.16'
}
'
@tanstack/virtual-core@3.13.23'
:
resolution
:
{
integrity
:
sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==
}
...
...
@@ -5867,6 +5874,8 @@ snapshots:
dependencies
:
react
:
19.2.3
'
@stripe/stripe-js@9.0.1'
:
{}
'
@tanstack/virtual-core@3.13.23'
:
{}
'
@tanstack/vue-virtual@3.13.23(vue@3.5.26(typescript@5.6.3))'
:
...
...
frontend/src/api/admin/index.ts
View file @
97f14b7a
...
...
@@ -26,6 +26,7 @@ import scheduledTestsAPI from './scheduledTests'
import
backupAPI
from
'
./backup
'
import
tlsFingerprintProfileAPI
from
'
./tlsFingerprintProfile
'
import
channelsAPI
from
'
./channels
'
import
adminPaymentAPI
from
'
./payment
'
/**
* Unified admin API object for convenient access
...
...
@@ -53,7 +54,8 @@ export const adminAPI = {
scheduledTests
:
scheduledTestsAPI
,
backup
:
backupAPI
,
tlsFingerprintProfiles
:
tlsFingerprintProfileAPI
,
channels
:
channelsAPI
channels
:
channelsAPI
,
payment
:
adminPaymentAPI
}
export
{
...
...
@@ -79,7 +81,8 @@ export {
scheduledTestsAPI
,
backupAPI
,
tlsFingerprintProfileAPI
,
channelsAPI
channelsAPI
,
adminPaymentAPI
}
export
default
adminAPI
...
...
frontend/src/api/admin/payment.ts
0 → 100644
View file @
97f14b7a
/**
* Admin Payment API endpoints
* Handles payment management operations for administrators
*/
import
{
apiClient
}
from
'
../client
'
import
type
{
DashboardStats
,
PaymentOrder
,
PaymentChannel
,
SubscriptionPlan
,
ProviderInstance
}
from
'
@/types/payment
'
import
type
{
BasePaginationResponse
}
from
'
@/types
'
/** Admin-facing payment config returned by GET /admin/payment/config */
export
interface
AdminPaymentConfig
{
enabled
:
boolean
min_amount
:
number
max_amount
:
number
daily_limit
:
number
order_timeout_minutes
:
number
max_pending_orders
:
number
enabled_payment_types
:
string
[]
balance_disabled
:
boolean
load_balance_strategy
:
string
product_name_prefix
:
string
product_name_suffix
:
string
help_image_url
:
string
help_text
:
string
}
/** Fields accepted by PUT /admin/payment/config (all optional via pointer semantics) */
export
interface
UpdatePaymentConfigRequest
{
enabled
?:
boolean
min_amount
?:
number
max_amount
?:
number
daily_limit
?:
number
order_timeout_minutes
?:
number
max_pending_orders
?:
number
enabled_payment_types
?:
string
[]
balance_disabled
?:
boolean
load_balance_strategy
?:
string
product_name_prefix
?:
string
product_name_suffix
?:
string
help_image_url
?:
string
help_text
?:
string
}
export
const
adminPaymentAPI
=
{
// ==================== Config ====================
/** Get payment configuration (admin view) */
getConfig
()
{
return
apiClient
.
get
<
AdminPaymentConfig
>
(
'
/admin/payment/config
'
)
},
/** Update payment configuration */
updateConfig
(
data
:
UpdatePaymentConfigRequest
)
{
return
apiClient
.
put
(
'
/admin/payment/config
'
,
data
)
},
// ==================== Dashboard ====================
/** Get payment dashboard statistics */
getDashboard
(
days
?:
number
)
{
return
apiClient
.
get
<
DashboardStats
>
(
'
/admin/payment/dashboard
'
,
{
params
:
days
?
{
days
}
:
undefined
})
},
// ==================== Orders ====================
/** Get all orders (paginated, with filters) */
getOrders
(
params
?:
{
page
?:
number
page_size
?:
number
status
?:
string
payment_type
?:
string
user_id
?:
number
keyword
?:
string
start_date
?:
string
end_date
?:
string
order_type
?:
string
})
{
return
apiClient
.
get
<
BasePaginationResponse
<
PaymentOrder
>>
(
'
/admin/payment/orders
'
,
{
params
})
},
/** Get a specific order by ID */
getOrder
(
id
:
number
)
{
return
apiClient
.
get
<
PaymentOrder
>
(
`/admin/payment/orders/
${
id
}
`
)
},
/** Cancel an order (admin) */
cancelOrder
(
id
:
number
)
{
return
apiClient
.
post
(
`/admin/payment/orders/
${
id
}
/cancel`
)
},
/** Retry recharge for a failed order */
retryRecharge
(
id
:
number
)
{
return
apiClient
.
post
(
`/admin/payment/orders/
${
id
}
/retry`
)
},
/** Process a refund */
refundOrder
(
id
:
number
,
data
:
{
amount
:
number
;
reason
:
string
;
deduct_balance
?:
boolean
;
force
?:
boolean
})
{
return
apiClient
.
post
(
`/admin/payment/orders/
${
id
}
/refund`
,
data
)
},
// ==================== Channels ====================
/** Get all payment channels */
getChannels
()
{
return
apiClient
.
get
<
PaymentChannel
[]
>
(
'
/admin/payment/channels
'
)
},
/** Create a payment channel */
createChannel
(
data
:
Partial
<
PaymentChannel
>
)
{
return
apiClient
.
post
<
PaymentChannel
>
(
'
/admin/payment/channels
'
,
data
)
},
/** Update a payment channel */
updateChannel
(
id
:
number
,
data
:
Partial
<
PaymentChannel
>
)
{
return
apiClient
.
put
<
PaymentChannel
>
(
`/admin/payment/channels/
${
id
}
`
,
data
)
},
/** Delete a payment channel */
deleteChannel
(
id
:
number
)
{
return
apiClient
.
delete
(
`/admin/payment/channels/
${
id
}
`
)
},
// ==================== Subscription Plans ====================
/** Get all subscription plans */
getPlans
()
{
return
apiClient
.
get
<
SubscriptionPlan
[]
>
(
'
/admin/payment/plans
'
)
},
/** Create a subscription plan */
createPlan
(
data
:
Record
<
string
,
unknown
>
)
{
return
apiClient
.
post
<
SubscriptionPlan
>
(
'
/admin/payment/plans
'
,
data
)
},
/** Update a subscription plan */
updatePlan
(
id
:
number
,
data
:
Record
<
string
,
unknown
>
)
{
return
apiClient
.
put
<
SubscriptionPlan
>
(
`/admin/payment/plans/
${
id
}
`
,
data
)
},
/** Delete a subscription plan */
deletePlan
(
id
:
number
)
{
return
apiClient
.
delete
(
`/admin/payment/plans/
${
id
}
`
)
},
// ==================== Provider Instances ====================
/** Get all provider instances */
getProviders
()
{
return
apiClient
.
get
<
ProviderInstance
[]
>
(
'
/admin/payment/providers
'
)
},
/** Create a provider instance */
createProvider
(
data
:
Partial
<
ProviderInstance
>
)
{
return
apiClient
.
post
<
ProviderInstance
>
(
'
/admin/payment/providers
'
,
data
)
},
/** Update a provider instance */
updateProvider
(
id
:
number
,
data
:
Partial
<
ProviderInstance
>
)
{
return
apiClient
.
put
<
ProviderInstance
>
(
`/admin/payment/providers/
${
id
}
`
,
data
)
},
/** Delete a provider instance */
deleteProvider
(
id
:
number
)
{
return
apiClient
.
delete
(
`/admin/payment/providers/
${
id
}
`
)
}
}
export
default
adminPaymentAPI
frontend/src/api/admin/settings.ts
View file @
97f14b7a
...
...
@@ -38,8 +38,6 @@ export interface SystemSettings {
doc_url
:
string
home_content
:
string
hide_ccs_import_button
:
boolean
purchase_subscription_enabled
:
boolean
purchase_subscription_url
:
string
table_default_page_size
:
number
table_page_size_options
:
number
[]
backend_mode_enabled
:
boolean
...
...
@@ -116,6 +114,26 @@ export interface SystemSettings {
enable_fingerprint_unification
:
boolean
enable_metadata_passthrough
:
boolean
enable_cch_signing
:
boolean
// Payment configuration
payment_enabled
:
boolean
payment_min_amount
:
number
payment_max_amount
:
number
payment_daily_limit
:
number
payment_order_timeout_minutes
:
number
payment_max_pending_orders
:
number
payment_enabled_types
:
string
[]
payment_balance_disabled
:
boolean
payment_load_balance_strategy
:
string
payment_product_name_prefix
:
string
payment_product_name_suffix
:
string
payment_help_image_url
:
string
payment_help_text
:
string
payment_cancel_rate_limit_enabled
:
boolean
payment_cancel_rate_limit_max
:
number
payment_cancel_rate_limit_window
:
number
payment_cancel_rate_limit_unit
:
string
payment_cancel_rate_limit_window_mode
:
string
}
export
interface
UpdateSettingsRequest
{
...
...
@@ -138,8 +156,6 @@ export interface UpdateSettingsRequest {
doc_url
?:
string
home_content
?:
string
hide_ccs_import_button
?:
boolean
purchase_subscription_enabled
?:
boolean
purchase_subscription_url
?:
string
table_default_page_size
?:
number
table_page_size_options
?:
number
[]
backend_mode_enabled
?:
boolean
...
...
@@ -198,6 +214,25 @@ export interface UpdateSettingsRequest {
enable_fingerprint_unification
?:
boolean
enable_metadata_passthrough
?:
boolean
enable_cch_signing
?:
boolean
// Payment configuration
payment_enabled
?:
boolean
payment_min_amount
?:
number
payment_max_amount
?:
number
payment_daily_limit
?:
number
payment_order_timeout_minutes
?:
number
payment_max_pending_orders
?:
number
payment_enabled_types
?:
string
[]
payment_balance_disabled
?:
boolean
payment_load_balance_strategy
?:
string
payment_product_name_prefix
?:
string
payment_product_name_suffix
?:
string
payment_help_image_url
?:
string
payment_help_text
?:
string
payment_cancel_rate_limit_enabled
?:
boolean
payment_cancel_rate_limit_max
?:
number
payment_cancel_rate_limit_window
?:
number
payment_cancel_rate_limit_unit
?:
string
payment_cancel_rate_limit_window_mode
?:
string
}
/**
...
...
frontend/src/api/client.ts
View file @
97f14b7a
...
...
@@ -92,10 +92,13 @@ apiClient.interceptors.response.use(
response
.
data
=
apiResponse
.
data
}
else
{
// API error
const
resp
=
apiResponse
as
Record
<
string
,
unknown
>
return
Promise
.
reject
({
status
:
response
.
status
,
code
:
apiResponse
.
code
,
message
:
apiResponse
.
message
||
'
Unknown error
'
message
:
apiResponse
.
message
||
'
Unknown error
'
,
reason
:
resp
.
reason
,
metadata
:
resp
.
metadata
,
})
}
}
...
...
@@ -268,7 +271,9 @@ apiClient.interceptors.response.use(
status
,
code
:
apiData
.
code
,
error
:
apiData
.
error
,
message
:
apiData
.
message
||
apiData
.
detail
||
error
.
message
message
:
apiData
.
message
||
apiData
.
detail
||
error
.
message
,
reason
:
apiData
.
reason
,
metadata
:
apiData
.
metadata
,
})
}
...
...
Prev
1
2
3
4
5
6
7
8
9
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment