From 789d451ea2be0efb299e848f7a3ca721fa64a230 Mon Sep 17 00:00:00 2001
From: ssongliu <73214554+ssongliu@users.noreply.github.com>
Date: Wed, 3 Sep 2025 13:59:28 +0800
Subject: [PATCH] feat: Add ZIP support for database backup uploads (#10246)
Refs #7179
---
agent/app/service/backup_mysql.go | 94 ++++++++++++++----------
agent/app/service/backup_postgresql.go | 44 ++---------
frontend/src/components/upload/index.vue | 48 ++++++++----
frontend/src/lang/modules/en.ts | 7 +-
frontend/src/lang/modules/ja.ts | 8 +-
frontend/src/lang/modules/ko.ts | 7 +-
frontend/src/lang/modules/ms.ts | 7 +-
frontend/src/lang/modules/pt-br.ts | 8 +-
frontend/src/lang/modules/ru.ts | 7 +-
frontend/src/lang/modules/tr.ts | 7 +-
frontend/src/lang/modules/zh-Hant.ts | 7 +-
frontend/src/lang/modules/zh.ts | 7 +-
12 files changed, 125 insertions(+), 126 deletions(-)
diff --git a/agent/app/service/backup_mysql.go b/agent/app/service/backup_mysql.go
index 8c4190552..765b1f0cf 100644
--- a/agent/app/service/backup_mysql.go
+++ b/agent/app/service/backup_mysql.go
@@ -59,44 +59,12 @@ func (u *BackupService) MysqlRecover(req dto.CommonRecover) error {
}
func (u *BackupService) MysqlRecoverByUpload(req dto.CommonRecover) error {
- file := req.File
- fileName := path.Base(req.File)
- if strings.HasSuffix(fileName, ".tar.gz") {
- fileNameItem := time.Now().Format(constant.DateTimeSlimLayout)
- dstDir := fmt.Sprintf("%s/%s", path.Dir(req.File), fileNameItem)
- fileOp := files.NewFileOp()
- if !fileOp.Stat(dstDir) {
- if err := fileOp.CreateDir(dstDir, os.ModePerm); err != nil {
- return fmt.Errorf("mkdir %s failed, err: %v", dstDir, err)
- }
- }
- if err := fileOp.TarGzExtractPro(req.File, dstDir, ""); err != nil {
- _ = os.RemoveAll(dstDir)
- return err
- }
- global.LOG.Infof("decompress file %s successful, now start to check test.sql is exist", req.File)
- hasTestSql := false
- _ = filepath.Walk(dstDir, func(path string, info os.FileInfo, err error) error {
- if err != nil {
- return nil
- }
- if !info.IsDir() && info.Name() == "test.sql" {
- hasTestSql = true
- file = path
- fileName = "test.sql"
- }
- return nil
- })
- if !hasTestSql {
- _ = os.RemoveAll(dstDir)
- return fmt.Errorf("no such file named test.sql in %s", fileName)
- }
- defer func() {
- _ = os.RemoveAll(dstDir)
- }()
+ recoveFile, err := loadSqlFile(req.File)
+ if err != nil {
+ return err
}
-
- req.File = path.Dir(file) + "/" + fileName
+ req.File = recoveFile
+ defer os.RemoveAll(path.Dir(recoveFile))
if err := handleMysqlRecover(req, nil, false, req.TaskID); err != nil {
return err
}
@@ -243,3 +211,55 @@ func doMysqlBackup(db DatabaseHelper, targetDir, fileName string) error {
}
return cli.Backup(backupInfo)
}
+
+func loadSqlFile(file string) (string, error) {
+ if !strings.HasSuffix(file, ".tar.gz") && !strings.HasSuffix(file, ".zip") {
+ return file, nil
+ }
+ fileName := path.Base(file)
+ fileDir := path.Dir(file)
+ fileNameItem := time.Now().Format(constant.DateTimeSlimLayout)
+ dstDir := fmt.Sprintf("%s/%s", fileDir, fileNameItem)
+ _ = os.Mkdir(dstDir, constant.DirPerm)
+ if strings.HasSuffix(fileName, ".tar.gz") {
+ fileOp := files.NewFileOp()
+ if err := fileOp.TarGzExtractPro(file, dstDir, ""); err != nil {
+ _ = os.RemoveAll(dstDir)
+ return "", err
+ }
+ }
+ if strings.HasSuffix(fileName, ".zip") {
+ archiver, err := files.NewShellArchiver(files.Zip)
+ if err != nil {
+ _ = os.RemoveAll(dstDir)
+ return "", err
+ }
+ if err := archiver.Extract(file, dstDir, ""); err != nil {
+ _ = os.RemoveAll(dstDir)
+ return "", err
+ }
+ }
+ global.LOG.Infof("decompress file %s successful, now start to check test.sql is exist", file)
+ var sqlFiles []string
+ hasTestSql := false
+ _ = filepath.Walk(dstDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil
+ }
+ if !info.IsDir() && strings.HasSuffix(info.Name(), ".sql") {
+ sqlFiles = append(sqlFiles, path)
+ if info.Name() == "test.sql" {
+ hasTestSql = true
+ }
+ }
+ return nil
+ })
+ if len(sqlFiles) == 1 {
+ return sqlFiles[0], nil
+ }
+ if !hasTestSql {
+ _ = os.RemoveAll(dstDir)
+ return "", fmt.Errorf("no such file named test.sql in %s", fileName)
+ }
+ return "", nil
+}
diff --git a/agent/app/service/backup_postgresql.go b/agent/app/service/backup_postgresql.go
index 747621d4a..ceae64124 100644
--- a/agent/app/service/backup_postgresql.go
+++ b/agent/app/service/backup_postgresql.go
@@ -4,8 +4,6 @@ import (
"fmt"
"os"
"path"
- "path/filepath"
- "strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/repo"
@@ -59,44 +57,12 @@ func (u *BackupService) PostgresqlRecover(req dto.CommonRecover) error {
}
func (u *BackupService) PostgresqlRecoverByUpload(req dto.CommonRecover) error {
- file := req.File
- fileName := path.Base(req.File)
- if strings.HasSuffix(fileName, ".tar.gz") {
- fileNameItem := time.Now().Format(constant.DateTimeSlimLayout)
- dstDir := fmt.Sprintf("%s/%s", path.Dir(req.File), fileNameItem)
- fileOp := files.NewFileOp()
- if !fileOp.Stat(dstDir) {
- if err := fileOp.CreateDir(dstDir, os.ModePerm); err != nil {
- return fmt.Errorf("mkdir %s failed, err: %v", dstDir, err)
- }
- }
- if err := fileOp.TarGzExtractPro(req.File, dstDir, ""); err != nil {
- _ = os.RemoveAll(dstDir)
- return err
- }
- global.LOG.Infof("decompress file %s successful, now start to check test.sql is exist", req.File)
- hasTestSql := false
- _ = filepath.Walk(dstDir, func(path string, info os.FileInfo, err error) error {
- if err != nil {
- return nil
- }
- if !info.IsDir() && info.Name() == "test.sql" {
- hasTestSql = true
- file = path
- fileName = "test.sql"
- }
- return nil
- })
- if !hasTestSql {
- _ = os.RemoveAll(dstDir)
- return fmt.Errorf("no such file named test.sql in %s", fileName)
- }
- defer func() {
- _ = os.RemoveAll(dstDir)
- }()
+ recoveFile, err := loadSqlFile(req.File)
+ if err != nil {
+ return err
}
-
- req.File = path.Dir(file) + "/" + fileName
+ req.File = recoveFile
+ defer os.RemoveAll(path.Dir(recoveFile))
if err := handlePostgresqlRecover(req, nil, false); err != nil {
return err
}
diff --git a/frontend/src/components/upload/index.vue b/frontend/src/components/upload/index.vue
index 09aeef903..e8d607ee9 100644
--- a/frontend/src/components/upload/index.vue
+++ b/frontend/src/components/upload/index.vue
@@ -16,15 +16,9 @@
{{ $t('database.formatHelper', [remark]) }}
- {{ $t('website.websiteBackupWarn') }}
-
- {{ $t('database.supportUpType') }}
- {{ $t('database.zipFormat') }}
-
-
- {{ $t('website.supportUpType') }}
- {{ $t('website.zipFormat', [type + '.json']) }}
-
+ {{ $t('database.supportUpType') }}
+ {{ $t('website.websiteBackupWarn') }}
+ {{ $t('website.supportUpType', [type]) }}
@@ -42,7 +36,7 @@
:limit="1"
class="float-left"
ref="uploadRef"
- accept=".tar.gz,.sql,.sql.gz"
+ accept=".tar.gz,.sql,.gz,.zip"
:show-file-list="false"
:on-exceed="handleExceed"
:on-change="fileOnChange"
@@ -156,7 +150,7 @@ const paginationConfig = reactive({
total: 0,
});
const uploadOpen = ref(false);
-const type = ref();
+const type = ref('mysql');
const name = ref();
const detailName = ref();
const remark = ref();
@@ -208,15 +202,38 @@ const search = async () => {
paginationConfig.total = res.data.total;
};
+const beforeUpload = (fileName: string) => {
+ const itemName = fileName.toLowerCase();
+ let reg = /^[a-zA-Z0-9\u4e00-\u9fa5]{1}[a-z:A-Z0-9_.\u4e00-\u9fa5-]{0,256}$/;
+ if (!reg.test(itemName)) {
+ MsgError(i18n.global.t('commons.msg.fileNameErr'));
+ return false;
+ }
+ if (isDb()) {
+ const allowedExtensions = ['.sql', '.sql.gz', '.tar.gz', '.zip'];
+ const isValidFile = allowedExtensions.some((ext) => itemName.endsWith(ext));
+ if (!isValidFile) {
+ MsgError(i18n.global.t('database.supportUpType'));
+ return false;
+ }
+ return true;
+ }
+ const allowedExtensions = ['.tar.gz'];
+ const isValidFile = allowedExtensions.some((ext) => itemName.endsWith(ext));
+ if (!isValidFile) {
+ MsgError(i18n.global.t('website.supportUpType'));
+ return false;
+ }
+ return true;
+};
+
const loadFile = async (path: string) => {
let filaName = path.split('/').pop();
if (!filaName) {
MsgError(i18n.global.t('commons.msg.fileNameErr'));
return;
}
- let reg = /^[a-zA-Z0-9\u4e00-\u9fa5]{1}[a-z:A-Z0-9_.\u4e00-\u9fa5-]{0,256}$/;
- if (!reg.test(filaName)) {
- MsgError(i18n.global.t('commons.msg.fileNameErr'));
+ if (!beforeUpload(filaName)) {
return;
}
ElMessageBox.confirm(i18n.global.t('database.selectHelper', [path]), i18n.global.t('database.loadBackup'), {
@@ -297,6 +314,9 @@ const fileOnChange = (_uploadFile: UploadFile, uploadFiles: UploadFiles) => {
MsgError(i18n.global.t('commons.msg.fileNameErr'));
return;
}
+ if (!beforeUpload(file.raw.name)) {
+ return;
+ }
ElMessageBox.confirm(
i18n.global.t('database.selectHelper', [file.raw.name]),
i18n.global.t('database.loadBackup'),
diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts
index 34870d379..44beed4d3 100644
--- a/frontend/src/lang/modules/en.ts
+++ b/frontend/src/lang/modules/en.ts
@@ -521,8 +521,8 @@ const message = {
selectFile: 'Select file',
dropHelper: 'You can drag and drop the uploaded file here or',
clickHelper: 'click to upload',
- supportUpType: 'Only sql, sql.gz, and tar.gz files are supported',
- zipFormat: 'tar.gz compressed package structure: test.tar.gz compressed package must contain test.sql',
+ supportUpType:
+ 'Only supports sql, sql.gz, tar.gz, .zip file formats. The imported compressed file must contain only one .sql file or include test.sql',
currentStatus: 'Current state',
baseParam: 'Basic parameter',
@@ -2211,8 +2211,7 @@ const message = {
otherDomains: 'Other domains',
static: 'Static',
deployment: 'Deployment',
- supportUpType: 'Only .tar.gz files are supported',
- zipFormat: '.tar.gz compressed package structure: test.tar.gz compressed package must contain {0} file',
+ supportUpType: 'Only .tar.gz file format is supported, and the compressed package must contain {0}.json file',
proxy: 'Reverse proxy',
alias: 'Alias',
ftpUser: 'FTP account',
diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts
index 5ffe5befb..89f44a4d0 100644
--- a/frontend/src/lang/modules/ja.ts
+++ b/frontend/src/lang/modules/ja.ts
@@ -509,8 +509,8 @@ const message = {
selectFile: '[ファイル]を選択します',
dropHelper: 'ここでアップロードされたファイルをドラッグアンドドロップするか、',
clickHelper: 'クリックしてアップロードします',
- supportUpType: 'SQL、SQL.GZ、およびTAR.GZファイルのみがサポートされています',
- zipFormat: 'tar.gz圧縮パッケージ構造:test.tar.gz圧縮パッケージにはtest.sqlが含まれている必要があります',
+ supportUpType:
+ 'sql、sql.gz、tar.gz、.zip ファイル形式のみサポートしています。インポートする圧縮ファイルには、1つの.sqlファイルのみ、またはtest.sqlが含まれている必要があります',
currentStatus: '現在の状態',
baseParam: '基本パラメーター',
@@ -2125,8 +2125,8 @@ const message = {
otherDomains: '他のドメイン',
static: '静的',
deployment: '展開',
- supportUpType: '.tar.gzファイルのみがサポートされています',
- zipFormat: '.tar.gz圧縮パッケージ構造:test.tar.gz圧縮パッケージは{0}ファイルを含める必要があります',
+ supportUpType:
+ '.tar.gz ファイル形式のみサポートされており、圧縮パッケージには {0}.json ファイルが含まれている必要があります',
proxy: '逆プロキシ',
alias: 'エイリアス',
ftpUser: 'FTPアカウント',
diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts
index a1bcf4f65..dd75093d8 100644
--- a/frontend/src/lang/modules/ko.ts
+++ b/frontend/src/lang/modules/ko.ts
@@ -507,8 +507,8 @@ const message = {
selectFile: '파일 선택',
dropHelper: '여기에 업로드한 파일을 드래그 앤 드롭하거나',
clickHelper: '클릭하여 업로드',
- supportUpType: 'sql, sql.gz, tar.gz 파일만 지원됩니다.',
- zipFormat: 'tar.gz 압축 패키지 구조: test.tar.gz 압축 패키지에는 test.sql이 포함되어야 합니다.',
+ supportUpType:
+ 'sql, sql.gz, tar.gz, .zip 파일 형식만 지원합니다. 가져오는 압축 파일에는 하나의 .sql 파일만 있거나 test.sql이 포함되어 있어야 합니다',
currentStatus: '현재 상태',
baseParam: '기본 파라미터',
@@ -2090,8 +2090,7 @@ const message = {
otherDomains: '기타 도메인',
static: '정적',
deployment: '배포',
- supportUpType: '지원되는 파일 형식: .tar.gz',
- zipFormat: '.tar.gz 압축 패키지 구조: test.tar.gz 패키지에는 반드시 {0} 파일이 포함되어야 합니다.',
+ supportUpType: '.tar.gz 파일 형식만 지원되며, 압축 패키지에는 {0}.json 파일이 포함되어야 합니다',
proxy: '리버스 프록시',
alias: '별칭',
ftpUser: 'FTP 계정',
diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts
index fd38bedd9..f8b64254b 100644
--- a/frontend/src/lang/modules/ms.ts
+++ b/frontend/src/lang/modules/ms.ts
@@ -521,8 +521,8 @@ const message = {
selectFile: 'Pilih fail',
dropHelper: 'Anda boleh seret dan lepaskan fail yang ingin dimuat naik di sini atau',
clickHelper: 'klik untuk memuat naik',
- supportUpType: 'Hanya fail sql, sql.gz, dan tar.gz yang disokong',
- zipFormat: 'Struktur pakej mampatan tar.gz: Pakej mampatan test.tar.gz mesti mengandungi test.sql',
+ supportUpType:
+ 'Hanya menyokong format fail sql, sql.gz, tar.gz, .zip. Fail termampat yang diimport mesti mengandungi hanya satu fail .sql atau termasuk test.sql',
currentStatus: 'Keadaan semasa',
baseParam: 'Parameter asas',
@@ -2181,8 +2181,7 @@ const message = {
otherDomains: 'Domain Lain',
static: 'Statik',
deployment: 'Penerapan',
- supportUpType: 'Hanya fail .tar.gz disokong',
- zipFormat: 'Struktur fail .tar.gz: fail test.tar.gz mesti mengandungi fail {0}',
+ supportUpType: 'Hanya format fail .tar.gz yang disokong, dan pakej termampat mesti mengandungi fail {0}.json',
proxy: 'Proksi Terbalik',
alias: 'Alias',
ftpUser: 'Akaun FTP',
diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts
index 667380021..285413303 100644
--- a/frontend/src/lang/modules/pt-br.ts
+++ b/frontend/src/lang/modules/pt-br.ts
@@ -519,8 +519,8 @@ const message = {
selectFile: 'Selecionar arquivo',
dropHelper: 'Você pode arrastar e soltar o arquivo carregado aqui ou',
clickHelper: 'clicar para fazer upload',
- supportUpType: 'Apenas arquivos sql, sql.gz e tar.gz são suportados',
- zipFormat: 'Estrutura do pacote comprimido tar.gz: o pacote comprimido test.tar.gz deve conter test.sql',
+ supportUpType:
+ 'Suporta apenas os formatos de arquivo sql, sql.gz, tar.gz, .zip. O arquivo compactado importado deve conter apenas um arquivo .sql ou incluir test.sql',
currentStatus: 'Estado atual',
baseParam: 'Parâmetro básico',
@@ -2178,8 +2178,8 @@ const message = {
otherDomains: 'Outros domínios',
static: 'Estático',
deployment: 'Implantação',
- supportUpType: 'Somente arquivos .tar.gz são suportados',
- zipFormat: 'Estrutura de pacote comprimido .tar.gz: o pacote comprimido test.tar.gz deve conter o arquivo {0}',
+ supportUpType:
+ 'Apenas o formato de arquivo .tar.gz é suportado, e o pacote compactado deve conter o arquivo {0}.json',
proxy: 'Proxy reverso',
alias: 'Alias',
ftpUser: 'Conta FTP',
diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts
index 9338e8133..dbfb3f44c 100644
--- a/frontend/src/lang/modules/ru.ts
+++ b/frontend/src/lang/modules/ru.ts
@@ -513,8 +513,8 @@ const message = {
selectFile: 'Выбрать файл',
dropHelper: 'Вы можете перетащить загружаемый файл сюда или',
clickHelper: 'нажмите для загрузки',
- supportUpType: 'Поддерживаются только файлы sql, sql.gz и tar.gz',
- zipFormat: 'Структура архива tar.gz: архив test.tar.gz должен содержать файл test.sql',
+ supportUpType:
+ 'Поддерживаются только форматы файлов sql, sql.gz, tar.gz, .zip. Импортируемый сжатый файл должен содержать только один файл .sql или включать test.sql',
currentStatus: 'Текущее состояние',
baseParam: 'Базовые параметры',
@@ -2174,8 +2174,7 @@ const message = {
otherDomains: 'Другие домены',
static: 'Статический',
deployment: 'Развертывание',
- supportUpType: 'Поддерживаются только файлы .tar.gz',
- zipFormat: 'Структура архива .tar.gz: архив test.tar.gz должен содержать файл {0}',
+ supportUpType: 'Поддерживается только формат файла .tar.gz, и сжатый пакет должен содержать файл {0}.json',
proxy: 'Обратный прокси',
alias: 'Псевдоним',
ftpUser: 'FTP аккаунт',
diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts
index 3e9906e18..d6fdd4500 100644
--- a/frontend/src/lang/modules/tr.ts
+++ b/frontend/src/lang/modules/tr.ts
@@ -528,8 +528,8 @@ const message = {
selectFile: 'Dosya seç',
dropHelper: 'Yüklenen dosyayı buraya sürükleyip bırakabilir veya',
clickHelper: 'yüklemek için tıklayın',
- supportUpType: 'Yalnızca sql, sql.gz ve tar.gz dosyaları desteklenir',
- zipFormat: 'tar.gz sıkıştırılmış paket yapısı: test.tar.gz sıkıştırılmış paketi test.sql içermelidir',
+ supportUpType:
+ 'Yalnızca sql, sql.gz, tar.gz, .zip dosya formatlarını destekler. İçe aktarılan sıkıştırılmış dosya yalnızca bir .sql dosyası içermeli veya test.sql içermelidir',
currentStatus: 'Mevcut durum',
baseParam: 'Temel parametre',
@@ -2237,8 +2237,7 @@ const message = {
otherDomains: 'Diğer alan adları',
static: 'Statik',
deployment: 'Dağıtım',
- supportUpType: 'Yalnızca .tar.gz dosyaları desteklenir',
- zipFormat: '.tar.gz sıkıştırılmış paket yapısı: test.tar.gz sıkıştırılmış paket {0} dosyasını içermelidir',
+ supportUpType: 'Yalnızca .tar.gz dosya formatı desteklenir ve sıkıştırılmış paket {0}.json dosyası içermelidir',
proxy: 'Ters vekil',
alias: 'Takma ad',
ftpUser: 'FTP hesabı',
diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts
index 688970605..41d332833 100644
--- a/frontend/src/lang/modules/zh-Hant.ts
+++ b/frontend/src/lang/modules/zh-Hant.ts
@@ -503,8 +503,8 @@ const message = {
selectFile: '選擇文件',
dropHelper: '將上傳文件拖拽到此處,或者',
clickHelper: '點擊上傳',
- supportUpType: '僅支持 sql、sql.gz、tar.gz 文件',
- zipFormat: 'tar.gz 壓縮包結構:test.tar.gz 壓縮包內,必需包含 test.sql',
+ supportUpType:
+ '僅支持 sql、sql.gz、tar.gz、.zip 文件格式,導入的壓縮文件必須保證只有一個 .sql 文件或者包含 test.sql',
currentStatus: '當前狀態',
baseParam: '基礎參數',
@@ -2059,8 +2059,7 @@ const message = {
otherDomains: '其他域名',
static: '靜態網站',
deployment: '一鍵部署',
- supportUpType: '僅支持 .tar.gz 文件',
- zipFormat: '.tar.gz 壓縮包結構:test.tar.gz 壓縮包內,必需包含 {0} 文件',
+ supportUpType: '僅支持 .tar.gz 文件格式,且壓縮包內必須包含 {0}.json 文件',
proxy: '反向代理',
alias: '代號',
ftpUser: 'FTP 帳號',
diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts
index 96d6ee023..74af68eb5 100644
--- a/frontend/src/lang/modules/zh.ts
+++ b/frontend/src/lang/modules/zh.ts
@@ -501,8 +501,8 @@ const message = {
selectFile: '选择文件',
dropHelper: '将上传文件拖拽到此处,或者',
clickHelper: '点击上传',
- supportUpType: '仅支持 sql、sql.gz、tar.gz 文件',
- zipFormat: 'tar.gz 压缩包结构:test.tar.gz 压缩包内,必需包含 test.sql',
+ supportUpType:
+ '仅支持 sql、sql.gz、tar.gz、.zip 文件格式,导入的压缩文件必须保证只有一个 .sql 文件或者包含 test.sql',
currentStatus: '当前状态',
baseParam: '基础参数',
@@ -2049,8 +2049,7 @@ const message = {
otherDomains: '其他域名',
static: '静态网站',
deployment: '一键部署',
- supportUpType: '仅支持 .tar.gz 文件',
- zipFormat: '.tar.gz 压缩包结构:test.tar.gz 压缩包内,必需包含 {0} 文件',
+ supportUpType: '仅支持 .tar.gz 文件格式,且压缩包内必须包含 {0}.json 文件',
proxy: '反向代理',
alias: '代号',
ftpUser: 'FTP 账号',