chore: seed migration objects batch 1
This commit is contained in:
@@ -0,0 +1,268 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/config"
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
var phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
|
||||||
|
|
||||||
|
type memberPhone struct {
|
||||||
|
id int64
|
||||||
|
tenantID int64
|
||||||
|
ciphertext string
|
||||||
|
hash string
|
||||||
|
mask string
|
||||||
|
phone string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
db, err := sql.Open("mysql", strings.TrimPrefix(cfg.MySQL.DSN, "mysql://"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
hasMobile, err := columnExists(ctx, db, "mobile")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
hasCipher, err := columnExists(ctx, db, "mobile_cipher")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if hasMobile && !hasCipher {
|
||||||
|
log.Print("hg_app_member already uses the mobile column")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !hasCipher {
|
||||||
|
log.Fatal("mobile_cipher is missing before migration")
|
||||||
|
}
|
||||||
|
|
||||||
|
aeads := newAEADs(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey))
|
||||||
|
lookupKeys := decodeKeys(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey))
|
||||||
|
overrides, err := parseOverrides(os.Getenv("WORKBENCH_MEMBER_MIGRATION_OVERRIDES"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(aeads) == 0 && len(lookupKeys) == 0 {
|
||||||
|
log.Fatal("no valid historical phone keys were provided")
|
||||||
|
}
|
||||||
|
phones, err := loadPhones(ctx, db, aeads, lookupKeys, overrides)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := validateUnique(phones); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if !hasMobile {
|
||||||
|
if _, err := db.ExecContext(ctx, `ALTER TABLE hg_app_member ADD COLUMN mobile varchar(20) NULL AFTER username`); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := updatePhones(ctx, db, phones); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `ALTER TABLE hg_app_member
|
||||||
|
MODIFY COLUMN mobile varchar(20) NOT NULL,
|
||||||
|
DROP INDEX uk_app_member_mobile_hash,
|
||||||
|
ADD UNIQUE KEY uk_app_member_mobile (tenant_id,mobile),
|
||||||
|
DROP COLUMN mobile_cipher,
|
||||||
|
DROP COLUMN mobile_hash,
|
||||||
|
DROP COLUMN mobile_mask`); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
log.Printf("migrated %d member phone numbers", len(phones))
|
||||||
|
}
|
||||||
|
|
||||||
|
func columnExists(ctx context.Context, db *sql.DB, column string) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='hg_app_member' AND COLUMN_NAME=?`, column).Scan(&count)
|
||||||
|
return count == 1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPhones(ctx context.Context, db *sql.DB, aeads []cipher.AEAD, lookupKeys [][]byte, overrides map[int64]string) ([]memberPhone, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT id,tenant_id,mobile_cipher,mobile_hash,mobile_mask FROM hg_app_member ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
phones := make([]memberPhone, 0)
|
||||||
|
failed := make([]string, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var item memberPhone
|
||||||
|
if err := rows.Scan(&item.id, &item.tenantID, &item.ciphertext, &item.hash, &item.mask); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.phone = recoverPhone(item, aeads, lookupKeys)
|
||||||
|
if item.phone == "" {
|
||||||
|
item.phone = overrides[item.id]
|
||||||
|
}
|
||||||
|
if !phonePattern.MatchString(item.phone) {
|
||||||
|
failed = append(failed, fmt.Sprintf("%d", item.id))
|
||||||
|
} else if len(item.mask) == 11 && item.phone[:3]+"****"+item.phone[7:] != item.mask {
|
||||||
|
return nil, fmt.Errorf("member %d override does not match its phone mask", item.id)
|
||||||
|
}
|
||||||
|
phones = append(phones, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(failed) > 0 {
|
||||||
|
return nil, fmt.Errorf("phone recovery failed for member IDs: %s", strings.Join(failed, ","))
|
||||||
|
}
|
||||||
|
return phones, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateUnique(phones []memberPhone) error {
|
||||||
|
seen := make(map[string]int64, len(phones))
|
||||||
|
for _, item := range phones {
|
||||||
|
key := fmt.Sprintf("%d:%s", item.tenantID, item.phone)
|
||||||
|
if otherID, ok := seen[key]; ok {
|
||||||
|
return fmt.Errorf("members %d and %d have duplicate phone numbers", otherID, item.id)
|
||||||
|
}
|
||||||
|
seen[key] = item.id
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePhones(ctx context.Context, db *sql.DB, phones []memberPhone) error {
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
for _, item := range phones {
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE hg_app_member SET mobile=? WHERE id=? AND tenant_id=?`, item.phone, item.id, item.tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected != 1 {
|
||||||
|
return fmt.Errorf("member %d update affected %d rows", item.id, affected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitKeys(value string) []string {
|
||||||
|
return strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == '\n' })
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOverrides(value string) (map[int64]string, error) {
|
||||||
|
result := make(map[int64]string)
|
||||||
|
for _, pair := range splitKeys(value) {
|
||||||
|
var id int64
|
||||||
|
var phone string
|
||||||
|
if _, err := fmt.Sscanf(pair, "%d=%s", &id, &phone); err != nil || id <= 0 || !phonePattern.MatchString(phone) {
|
||||||
|
return nil, fmt.Errorf("invalid member phone override")
|
||||||
|
}
|
||||||
|
result[id] = phone
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAEADs(encodedKeys []string) []cipher.AEAD {
|
||||||
|
result := make([]cipher.AEAD, 0, len(encodedKeys))
|
||||||
|
for _, encodedKey := range encodedKeys {
|
||||||
|
key, err := decodeKey(encodedKey)
|
||||||
|
if err != nil || len(key) != 32 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err == nil {
|
||||||
|
result = append(result, aead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeKeys(encodedKeys []string) [][]byte {
|
||||||
|
result := make([][]byte, 0, len(encodedKeys))
|
||||||
|
for _, encodedKey := range encodedKeys {
|
||||||
|
key, err := decodeKey(encodedKey)
|
||||||
|
if err == nil && len(key) >= 32 {
|
||||||
|
result = append(result, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeKey(value string) ([]byte, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString(value); err == nil {
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
return base64.RawStdEncoding.DecodeString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recoverPhone(item memberPhone, aeads []cipher.AEAD, lookupKeys [][]byte) string {
|
||||||
|
for _, aead := range aeads {
|
||||||
|
for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.RawURLEncoding} {
|
||||||
|
plain, err := decrypt(aead, encoding, item.ciphertext)
|
||||||
|
phone := strings.TrimPrefix(plain, "+86")
|
||||||
|
if err == nil && phonePattern.MatchString(phone) {
|
||||||
|
return phone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(item.mask) != 11 || item.mask[3:7] != "****" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for middle := 0; middle < 10000; middle++ {
|
||||||
|
phone := fmt.Sprintf("%s%04d%s", item.mask[:3], middle, item.mask[7:])
|
||||||
|
for _, value := range []string{phone, "+86" + phone} {
|
||||||
|
digest := sha256.Sum256([]byte(value))
|
||||||
|
if hmac.Equal([]byte(strings.ToLower(item.hash)), []byte(hex.EncodeToString(digest[:]))) {
|
||||||
|
return phone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range lookupKeys {
|
||||||
|
for _, value := range []string{phone, "+86" + phone} {
|
||||||
|
mac := hmac.New(sha256.New, key)
|
||||||
|
_, _ = mac.Write([]byte(value))
|
||||||
|
if hmac.Equal([]byte(strings.ToLower(item.hash)), []byte(hex.EncodeToString(mac.Sum(nil)))) {
|
||||||
|
return phone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func decrypt(aead cipher.AEAD, encoding *base64.Encoding, value string) (string, error) {
|
||||||
|
raw, err := encoding.DecodeString(value)
|
||||||
|
if err != nil || len(raw) < aead.NonceSize() {
|
||||||
|
return "", errors.New("invalid ciphertext")
|
||||||
|
}
|
||||||
|
plain, err := aead.Open(nil, raw[:aead.NonceSize()], raw[aead.NonceSize():], nil)
|
||||||
|
return string(plain), err
|
||||||
|
}
|
||||||
@@ -256,14 +256,14 @@ func parseFeatures(raw json.RawMessage) Features {
|
|||||||
return Features{}
|
return Features{}
|
||||||
}
|
}
|
||||||
return Features{
|
return Features{
|
||||||
TaskEnabled: findBool(config, "task_enabled", "taskEnabled", "TASK"),
|
TaskEnabled: findBool(config, true, "task_enabled", "taskEnabled", "TASK"),
|
||||||
RechargeEnabled: findBool(config, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
|
RechargeEnabled: findBool(config, true, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
|
||||||
WithdrawalEnabled: findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
|
WithdrawalEnabled: findBool(config, true, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
|
||||||
ManualMallEnabled: findBool(config, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"),
|
ManualMallEnabled: findBool(config, true, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func findBool(config map[string]any, keys ...string) bool {
|
func findBool(config map[string]any, fallback bool, keys ...string) bool {
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
if value, ok := config[key]; ok {
|
if value, ok := config[key]; ok {
|
||||||
return boolValue(value)
|
return boolValue(value)
|
||||||
@@ -276,7 +276,7 @@ func findBool(config map[string]any, keys ...string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
func boolValue(value any) bool {
|
func boolValue(value any) bool {
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ func TestParseFeaturesSupportsNestedAndLegacyKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseFeaturesDefaultsToEnabled(t *testing.T) {
|
||||||
|
features := parseFeatures(json.RawMessage(`{"siteName":"demo"}`))
|
||||||
|
if !features.TaskEnabled || !features.RechargeEnabled || !features.WithdrawalEnabled || !features.ManualMallEnabled {
|
||||||
|
t.Fatalf("unexpected feature defaults: %+v", features)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBannersSanitizeTargets(t *testing.T) {
|
func TestBannersSanitizeTargets(t *testing.T) {
|
||||||
service := New(fakeRepository{banners: []store.Banner{
|
service := New(fakeRepository{banners: []store.Banner{
|
||||||
{ID: 1, TargetType: 2, TargetValue: "/receive"},
|
{ID: 1, TargetType: 2, TargetValue: "/receive"},
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RechargeChannel struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
Code string `db:"channel_code"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
ChannelType int `db:"channel_type"`
|
||||||
|
MinAmount int64 `db:"min_amount"`
|
||||||
|
MaxAmount int64 `db:"max_amount"`
|
||||||
|
FeeType int `db:"fee_type"`
|
||||||
|
FeeFixedAmount int64 `db:"fee_fixed_amount"`
|
||||||
|
FeeRate int `db:"fee_rate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RechargeCreation struct {
|
||||||
|
TenantID int64
|
||||||
|
MemberID int64
|
||||||
|
RechargeNo string
|
||||||
|
ChannelID int64
|
||||||
|
RequestAmount int64
|
||||||
|
Currency string
|
||||||
|
VoucherAttachmentIDs []int64
|
||||||
|
Remark string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RechargeOrder struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
RechargeNo string `db:"recharge_no"`
|
||||||
|
ChannelID int64 `db:"channel_id"`
|
||||||
|
ChannelName string `db:"channel_name"`
|
||||||
|
RequestAmount int64 `db:"request_amount"`
|
||||||
|
PaidAmount int64 `db:"paid_amount"`
|
||||||
|
Currency string `db:"currency"`
|
||||||
|
VoucherAttachmentIDs json.RawMessage `db:"voucher_attachment_ids"`
|
||||||
|
Status int `db:"recharge_status"`
|
||||||
|
Remark string `db:"remark"`
|
||||||
|
ReviewReasonCode string `db:"review_reason_code"`
|
||||||
|
ReviewReason string `db:"review_reason"`
|
||||||
|
ExternalNo string `db:"external_no"`
|
||||||
|
PaidAt string `db:"paid_at"`
|
||||||
|
ReviewedAt string `db:"reviewed_at"`
|
||||||
|
CreditedAt string `db:"credited_at"`
|
||||||
|
CreatedAt string `db:"created_at"`
|
||||||
|
UpdatedAt string `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RechargePage struct {
|
||||||
|
Items []RechargeOrder
|
||||||
|
Total int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) {
|
||||||
|
items := make([]RechargeChannel, 0)
|
||||||
|
err := s.db.SelectContext(ctx, &items, `SELECT id,channel_code,name,channel_type,min_amount,max_amount,
|
||||||
|
fee_type,fee_fixed_amount,fee_rate FROM hg_payment_channel
|
||||||
|
WHERE tenant_id=? AND direction IN (1,3) AND status=1 AND deleted_at IS NULL ORDER BY sort,id`, tenantID)
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*RechargeOrder, error) {
|
||||||
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var memberID int64
|
||||||
|
err = tx.GetContext(ctx, &memberID, `SELECT id FROM hg_app_member WHERE tenant_id=? AND id=?
|
||||||
|
AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrAccountRestricted
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var channel RechargeChannel
|
||||||
|
err = tx.GetContext(ctx, &channel, `SELECT id,channel_code,name,channel_type,min_amount,max_amount,
|
||||||
|
fee_type,fee_fixed_amount,fee_rate FROM hg_payment_channel WHERE tenant_id=? AND id=?
|
||||||
|
AND direction IN (1,3) AND status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.ChannelID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrRechargeChannel
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if input.RequestAmount < maxInt64(1, channel.MinAmount) || (channel.MaxAmount > 0 && input.RequestAmount > channel.MaxAmount) {
|
||||||
|
return nil, ErrRechargeAmount
|
||||||
|
}
|
||||||
|
for _, attachmentID := range input.VoucherAttachmentIDs {
|
||||||
|
var count int
|
||||||
|
if err = tx.GetContext(ctx, &count, `SELECT COUNT(*) FROM hg_sys_attachment WHERE tenant_id=? AND id=?
|
||||||
|
AND app_id='workbench' AND member_id=? AND status=1`, input.TenantID, attachmentID, input.MemberID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
return nil, ErrRechargeVoucher
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vouchers, err := json.Marshal(input.VoucherAttachmentIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, `INSERT INTO hg_recharge_order
|
||||||
|
(tenant_id,recharge_no,member_id,channel_id,request_amount,paid_amount,currency,
|
||||||
|
voucher_attachment_ids,recharge_type,recharge_status,remark,created_by,updated_by)
|
||||||
|
VALUES(?,?,?,?,?,0,?,?,1,1,?,?,?)`, input.TenantID, input.RechargeNo, input.MemberID,
|
||||||
|
input.ChannelID, input.RequestAmount, input.Currency, vouchers, input.Remark, input.MemberID, input.MemberID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &RechargeOrder{ID: id, RechargeNo: input.RechargeNo, ChannelID: channel.ID,
|
||||||
|
ChannelName: channel.Name, RequestAmount: input.RequestAmount, Currency: input.Currency,
|
||||||
|
VoucherAttachmentIDs: vouchers, Status: 1, Remark: input.Remark}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RechargeOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*RechargePage, error) {
|
||||||
|
where := ` WHERE o.tenant_id=? AND o.member_id=?`
|
||||||
|
args := []any{tenantID, memberID}
|
||||||
|
if status > 0 {
|
||||||
|
where += ` AND o.recharge_status=?`
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_recharge_order o`+where, args...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
items := make([]RechargeOrder, 0)
|
||||||
|
err := s.db.SelectContext(ctx, &items, rechargeSelectSQL()+where+
|
||||||
|
` ORDER BY o.created_at DESC,o.id DESC LIMIT ? OFFSET ?`, args...)
|
||||||
|
return &RechargePage{Items: items, Total: total}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RechargeOrder(ctx context.Context, tenantID, memberID, orderID int64) (*RechargeOrder, error) {
|
||||||
|
var row RechargeOrder
|
||||||
|
err := s.db.GetContext(ctx, &row, rechargeSelectSQL()+
|
||||||
|
` WHERE o.tenant_id=? AND o.member_id=? AND o.id=? LIMIT 1`, tenantID, memberID, orderID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrRechargeNotFound
|
||||||
|
}
|
||||||
|
return &row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func rechargeSelectSQL() string {
|
||||||
|
return `SELECT o.id,o.recharge_no,o.channel_id,COALESCE(c.name,'') AS channel_name,o.request_amount,
|
||||||
|
o.paid_amount,o.currency,COALESCE(o.voucher_attachment_ids,JSON_ARRAY()) AS voucher_attachment_ids,
|
||||||
|
o.recharge_status,o.remark,o.review_reason_code,o.review_reason,o.external_no,
|
||||||
|
COALESCE(DATE_FORMAT(o.paid_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS paid_at,
|
||||||
|
COALESCE(DATE_FORMAT(o.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at,
|
||||||
|
COALESCE(DATE_FORMAT(o.credited_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS credited_at,
|
||||||
|
DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||||
|
DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
|
||||||
|
FROM hg_recharge_order o LEFT JOIN hg_payment_channel c ON c.tenant_id=o.tenant_id AND c.id=o.channel_id`
|
||||||
|
}
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
import type { MessageApi } from 'naive-ui';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
$message: MessageApi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<main class="m-app" :class="{ 'm-app--with-nav': showNav }">
|
||||||
|
<router-view />
|
||||||
|
<nav v-if="showNav" class="m-bottom-nav" aria-label="主要导航">
|
||||||
|
<router-link v-for="item in nav" :key="item.path" :to="item.path" :aria-label="item.label">
|
||||||
|
<img :src="isActive(item.path) ? item.activeIcon : item.icon" alt="" />
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { hasSession } from './api';
|
||||||
|
import { mobileAssets } from './assets';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const showNav = computed(() => Boolean(route.meta.mobileNav));
|
||||||
|
const nav = [
|
||||||
|
{
|
||||||
|
label: '首页',
|
||||||
|
path: '/app/home',
|
||||||
|
icon: mobileAssets.navHome,
|
||||||
|
activeIcon: mobileAssets.navHomeActive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '记录',
|
||||||
|
path: '/app/orders',
|
||||||
|
icon: mobileAssets.navOrders,
|
||||||
|
activeIcon: mobileAssets.navOrdersActive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '领取',
|
||||||
|
path: '/app/claim',
|
||||||
|
icon: mobileAssets.navClaimActive,
|
||||||
|
activeIcon: mobileAssets.navClaimActive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '客服',
|
||||||
|
path: '/app/support',
|
||||||
|
icon: mobileAssets.navSupport,
|
||||||
|
activeIcon: mobileAssets.navSupportActive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '我的',
|
||||||
|
path: '/app/profile',
|
||||||
|
icon: mobileAssets.navProfile,
|
||||||
|
activeIcon: mobileAssets.navProfileActive,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const isActive = (path: string) => route.path === path || route.path.startsWith(`${path}/`);
|
||||||
|
|
||||||
|
function ensureAccess() {
|
||||||
|
const publicContent = [
|
||||||
|
'/app/content/user_agreement',
|
||||||
|
'/app/content/privacy_policy',
|
||||||
|
'/app/content/platform_intro',
|
||||||
|
];
|
||||||
|
const publicPassword = ['/app/password/verify', '/app/password/reset'];
|
||||||
|
if (
|
||||||
|
!hasSession() &&
|
||||||
|
!publicContent.includes(route.path) &&
|
||||||
|
!publicPassword.includes(route.path)
|
||||||
|
) {
|
||||||
|
router.replace({ path: '/app/auth', query: { redirect: route.fullPath } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => route.path, ensureAccess, { immediate: true });
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'olons_web_session';
|
||||||
|
const DEVICE_KEY = 'olons_web_device';
|
||||||
|
|
||||||
|
const reasonMessages: Record<string, string> = {
|
||||||
|
AUTH_REQUIRED: '登录已失效,请重新登录',
|
||||||
|
FORBIDDEN: '当前账户无权执行此操作',
|
||||||
|
INVALID_CREDENTIALS: '手机号或密码错误',
|
||||||
|
ACCOUNT_ALREADY_REGISTERED: '该账户或手机号已经注册',
|
||||||
|
RATE_LIMITED: '操作过于频繁,请稍后再试',
|
||||||
|
TASK_DISABLED: '领取功能暂未开放',
|
||||||
|
OUTSIDE_BUSINESS_HOURS: '当前不在营业时间内',
|
||||||
|
INSUFFICIENT_BALANCE: '可用余额不足',
|
||||||
|
DAILY_LIMIT_REACHED: '今日领取次数已用完',
|
||||||
|
PENDING_ORDER_EXISTS: '存在未完成的任务,请先处理',
|
||||||
|
NO_TASK_AVAILABLE: '当前暂无可领取任务',
|
||||||
|
ORDER_EXPIRED: '该任务已超时',
|
||||||
|
ORDER_STATE_INVALID: '当前任务状态不允许此操作',
|
||||||
|
TRADE_PASSWORD_NOT_SET: '请先设置支付密码',
|
||||||
|
TRADE_PASSWORD_INVALID: '支付密码不正确',
|
||||||
|
PAYMENT_ACCOUNT_INVALID: '收款账户未核验或不可用',
|
||||||
|
WITHDRAWAL_DISABLED: '取出功能暂未开放',
|
||||||
|
WITHDRAWAL_AMOUNT_INVALID: '取出金额不符合当前规则',
|
||||||
|
WITHDRAWAL_DAILY_LIMIT_REACHED: '今日取出次数已用完',
|
||||||
|
WITHDRAWAL_CHANNEL_UNAVAILABLE: '当前没有可用的取出通道',
|
||||||
|
RECHARGE_AMOUNT_INVALID: '储入金额不符合当前通道规则',
|
||||||
|
RECHARGE_CHANNEL_UNAVAILABLE: '当前没有可用的储入通道',
|
||||||
|
RECHARGE_VOUCHER_INVALID: '储入凭证无效',
|
||||||
|
SERVICE_UNAVAILABLE: '服务暂不可用,请稍后重试',
|
||||||
|
INTERNAL: '服务异常,请稍后重试',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ApiEnvelope<T> {
|
||||||
|
code: number;
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
reason?: string;
|
||||||
|
request_id?: string;
|
||||||
|
server_time?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MobileApiError extends Error {
|
||||||
|
reason: string;
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(message: string, reason = '', status = 0) {
|
||||||
|
super(message);
|
||||||
|
this.reason = reason;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: '',
|
||||||
|
timeout: 15000,
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
client.interceptors.request.use((config) => {
|
||||||
|
const token = sessionStorage.getItem(TOKEN_KEY);
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
config.headers['X-Request-ID'] = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`;
|
||||||
|
config.headers['X-Device-ID'] = getDeviceID();
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error: AxiosError<ApiEnvelope<unknown>>) => {
|
||||||
|
const status = error.response?.status || 0;
|
||||||
|
const payload = error.response?.data;
|
||||||
|
if (status === 401) {
|
||||||
|
clearSession();
|
||||||
|
const currentPath = window.location.hash.replace(/^#/, '') || '/app/home';
|
||||||
|
if (!currentPath.startsWith('/app/auth')) {
|
||||||
|
window.location.hash = `/app/auth?redirect=${encodeURIComponent(currentPath)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const reason = payload?.reason || '';
|
||||||
|
return Promise.reject(
|
||||||
|
new MobileApiError(
|
||||||
|
reasonMessages[reason] ||
|
||||||
|
payload?.message ||
|
||||||
|
(status ? '请求失败,请稍后重试' : '网络连接失败'),
|
||||||
|
reason,
|
||||||
|
status
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
||||||
|
const method = String(config.method || 'GET').toUpperCase();
|
||||||
|
const attempts = method === 'GET' ? 3 : 1;
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const response = await client.request<ApiEnvelope<T>>(config);
|
||||||
|
if (response.data.code !== 0) {
|
||||||
|
const reason = response.data.reason || '';
|
||||||
|
throw new MobileApiError(
|
||||||
|
reasonMessages[reason] || response.data.message || '请求失败',
|
||||||
|
reason,
|
||||||
|
response.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (!shouldRetry(error, attempt, attempts)) throw error;
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, attempt === 0 ? 250 : 600));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetry(error: unknown, attempt: number, attempts: number) {
|
||||||
|
if (attempt >= attempts - 1) return false;
|
||||||
|
if (axios.isCancel(error)) return false;
|
||||||
|
return error instanceof MobileApiError && (error.status === 0 || error.status === 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeviceID() {
|
||||||
|
let value = localStorage.getItem(DEVICE_KEY);
|
||||||
|
if (!value) {
|
||||||
|
value = crypto.randomUUID?.() || `web-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
localStorage.setItem(DEVICE_KEY, value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSession(token: string) {
|
||||||
|
sessionStorage.setItem(TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession() {
|
||||||
|
sessionStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSession() {
|
||||||
|
return Boolean(sessionStorage.getItem(TOKEN_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function idempotencyKey() {
|
||||||
|
return crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function money(value?: number, currency = 'CNY') {
|
||||||
|
const amount = Number.isFinite(value) ? Number(value) / 100 : 0;
|
||||||
|
return new Intl.NumberFormat('zh-CN', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateTime(value?: string) {
|
||||||
|
if (!value) return '--';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '--';
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
.format(date)
|
||||||
|
.replaceAll('/', '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeImage(url?: string) {
|
||||||
|
if (!url) return '';
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, window.location.origin);
|
||||||
|
return parsed.protocol === 'https:' || parsed.origin === window.location.origin
|
||||||
|
? parsed.href
|
||||||
|
: '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorText(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : '操作失败,请稍后重试';
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import homeHero from './assets/home-hero.png';
|
||||||
|
import homeBalance from './assets/home-balance.png';
|
||||||
|
import shortcutClaim from './assets/shortcut-claim.png';
|
||||||
|
import shortcutRecharge from './assets/shortcut-recharge.png';
|
||||||
|
import shortcutWithdraw from './assets/shortcut-withdraw.png';
|
||||||
|
import shortcutMall from './assets/shortcut-mall.png';
|
||||||
|
import navHome from './assets/nav-home.png';
|
||||||
|
import navHomeActive from './assets/nav-home-active.png';
|
||||||
|
import navOrders from './assets/nav-orders.png';
|
||||||
|
import navOrdersActive from './assets/nav-orders-active.png';
|
||||||
|
import navClaimActive from './assets/nav-claim-active.png';
|
||||||
|
import navSupport from './assets/nav-support.png';
|
||||||
|
import navSupportActive from './assets/nav-support-active.png';
|
||||||
|
import navProfile from './assets/nav-profile.png';
|
||||||
|
import navProfileActive from './assets/nav-profile-active.png';
|
||||||
|
import profileBackground from './assets/profile-bg.png';
|
||||||
|
import profileWallet from './assets/profile-wallet.png';
|
||||||
|
import menuProfile from './assets/menu-profile.png';
|
||||||
|
import menuRecharge from './assets/menu-recharge.png';
|
||||||
|
import menuWithdraw from './assets/menu-withdraw.png';
|
||||||
|
import menuLedger from './assets/menu-ledger.png';
|
||||||
|
import menuNotices from './assets/menu-notices.png';
|
||||||
|
import menuLogout from './assets/menu-logout.png';
|
||||||
|
import copy from './assets/copy.png';
|
||||||
|
import supportHero from './assets/support-hero.png';
|
||||||
|
import back from './assets/back.png';
|
||||||
|
import claimOrbit from './assets/claim-orbit.png';
|
||||||
|
import claimGift from './assets/claim-gift.png';
|
||||||
|
import claimButton from './assets/claim-button.png';
|
||||||
|
import processSimple from './assets/process-simple.png';
|
||||||
|
import processMaterial from './assets/process-material.png';
|
||||||
|
import processTime from './assets/process-time.png';
|
||||||
|
import processReturn from './assets/process-return.png';
|
||||||
|
import chatVoice from './assets/chat-voice.png';
|
||||||
|
import chatMore from './assets/chat-more.png';
|
||||||
|
import companyPlatform from './assets/company-platform.png';
|
||||||
|
import companyRules from './assets/company-rules.png';
|
||||||
|
import companyQualification from './assets/company-qualification.png';
|
||||||
|
import companyAgency from './assets/company-agency.png';
|
||||||
|
import avatarEdit from './assets/avatar-edit.png';
|
||||||
|
import rechargeRecord from './assets/recharge-record.png';
|
||||||
|
import withdrawalRecord from './assets/withdrawal-record.png';
|
||||||
|
import loadingOrbit from './assets/loading-orbit.png';
|
||||||
|
import agencyHero from './assets/agency-hero.png';
|
||||||
|
import agencyBackground from './assets/agency-bg.png';
|
||||||
|
import agencyPersonPrimary from './assets/agency-person-primary.png';
|
||||||
|
import agencyPersonSecondary from './assets/agency-person-secondary.png';
|
||||||
|
import agencyPersonTertiary from './assets/agency-person-tertiary.png';
|
||||||
|
import rulesBackground from './assets/rules-bg.png';
|
||||||
|
import rulesBanner from './assets/rules-banner.png';
|
||||||
|
import levelSilver from './assets/level-silver.png';
|
||||||
|
import levelGold from './assets/level-gold.png';
|
||||||
|
import levelPlatinum from './assets/level-platinum.png';
|
||||||
|
import levelDiamond from './assets/level-diamond.png';
|
||||||
|
import levelStar from './assets/level-star.png';
|
||||||
|
import levelCrown from './assets/level-crown.png';
|
||||||
|
import claimMachine from './assets/claim-machine.png';
|
||||||
|
import claimAutoButton from './assets/claim-auto-button.png';
|
||||||
|
import claimTotal from './assets/claim-total.png';
|
||||||
|
import claimYesterday from './assets/claim-yesterday.png';
|
||||||
|
import claimToday from './assets/claim-today.png';
|
||||||
|
import claimCount from './assets/claim-count.png';
|
||||||
|
import claimSpinner from './assets/claim-spinner.png';
|
||||||
|
import claimMatchingGift from './assets/claim-matching-gift.png';
|
||||||
|
import claimSuccess from './assets/claim-success.png';
|
||||||
|
import claimFailed from './assets/claim-failed.png';
|
||||||
|
import claimCancelled from './assets/claim-cancelled.png';
|
||||||
|
import mallBanner from './assets/mall-banner.png';
|
||||||
|
import mallBackground from './assets/mall-bg.png';
|
||||||
|
import rankFirst from './assets/rank-first.png';
|
||||||
|
import rankSecond from './assets/rank-second.png';
|
||||||
|
import rankThird from './assets/rank-third.png';
|
||||||
|
import rankBackground from './assets/rank-bg.png';
|
||||||
|
import qualificationOperation from './assets/qualification-operation.png';
|
||||||
|
import qualificationDevelopment from './assets/qualification-development.png';
|
||||||
|
|
||||||
|
export const mobileAssets = {
|
||||||
|
homeHero,
|
||||||
|
homeBalance,
|
||||||
|
shortcutClaim,
|
||||||
|
shortcutRecharge,
|
||||||
|
shortcutWithdraw,
|
||||||
|
shortcutMall,
|
||||||
|
navHome,
|
||||||
|
navHomeActive,
|
||||||
|
navOrders,
|
||||||
|
navOrdersActive,
|
||||||
|
navClaimActive,
|
||||||
|
navSupport,
|
||||||
|
navSupportActive,
|
||||||
|
navProfile,
|
||||||
|
navProfileActive,
|
||||||
|
profileBackground,
|
||||||
|
profileWallet,
|
||||||
|
menuProfile,
|
||||||
|
menuRecharge,
|
||||||
|
menuWithdraw,
|
||||||
|
menuLedger,
|
||||||
|
menuNotices,
|
||||||
|
menuLogout,
|
||||||
|
copy,
|
||||||
|
supportHero,
|
||||||
|
back,
|
||||||
|
claimOrbit,
|
||||||
|
claimGift,
|
||||||
|
claimButton,
|
||||||
|
processSimple,
|
||||||
|
processMaterial,
|
||||||
|
processTime,
|
||||||
|
processReturn,
|
||||||
|
chatVoice,
|
||||||
|
chatMore,
|
||||||
|
companyPlatform,
|
||||||
|
companyRules,
|
||||||
|
companyQualification,
|
||||||
|
companyAgency,
|
||||||
|
avatarEdit,
|
||||||
|
rechargeRecord,
|
||||||
|
withdrawalRecord,
|
||||||
|
loadingOrbit,
|
||||||
|
agencyHero,
|
||||||
|
agencyBackground,
|
||||||
|
agencyPersonPrimary,
|
||||||
|
agencyPersonSecondary,
|
||||||
|
agencyPersonTertiary,
|
||||||
|
rulesBackground,
|
||||||
|
rulesBanner,
|
||||||
|
levelSilver,
|
||||||
|
levelGold,
|
||||||
|
levelPlatinum,
|
||||||
|
levelDiamond,
|
||||||
|
levelStar,
|
||||||
|
levelCrown,
|
||||||
|
claimMachine,
|
||||||
|
claimAutoButton,
|
||||||
|
claimTotal,
|
||||||
|
claimYesterday,
|
||||||
|
claimToday,
|
||||||
|
claimCount,
|
||||||
|
claimSpinner,
|
||||||
|
claimMatchingGift,
|
||||||
|
claimSuccess,
|
||||||
|
claimFailed,
|
||||||
|
claimCancelled,
|
||||||
|
mallBanner,
|
||||||
|
mallBackground,
|
||||||
|
rankFirst,
|
||||||
|
rankSecond,
|
||||||
|
rankThird,
|
||||||
|
rankBackground,
|
||||||
|
qualificationOperation,
|
||||||
|
qualificationDevelopment,
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 939 KiB |
Reference in New Issue
Block a user