提交 faf46745 编写于 作者: H HFO4

Feat: cron / Fix: users status check

上级 44d6ca48
......@@ -54,6 +54,8 @@ github.com/go-ini/ini v1.50.0 h1:ogX6RS8VstVN8MJcwhEP78hHhWaI3klN02+97bByabY=
github.com/go-ini/ini v1.50.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-mail/mail v2.3.1+incompatible h1:UzNOn0k5lpfVtO31cK3hn6I4VEVGhe3lX8AJBAxXExM=
github.com/go-mail/mail v2.3.1+incompatible/go.mod h1:VPWjmmNyRsWXQZHVHT3g0YbIINUkSmuKOiLIDkWbL6M=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
......@@ -165,6 +167,9 @@ github.com/quasoft/memstore v0.0.0-20180925164028-84a050167438/go.mod h1:wTPjTep
github.com/rafaeljusto/redigomock v0.0.0-20191117212112-00b2509252a1 h1:leEwA4MD1ew0lNgzz6Q4G76G3AEfeci+TMggN6WuFRs=
github.com/rafaeljusto/redigomock v0.0.0-20191117212112-00b2509252a1/go.mod h1:JaY6n2sDr+z2WTsXkOmNRUfDy6FN0L6Nk7x06ndm4tY=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
......
......@@ -7,6 +7,8 @@ import (
"github.com/HFO4/cloudreve/pkg/authn"
"github.com/HFO4/cloudreve/pkg/cache"
"github.com/HFO4/cloudreve/pkg/conf"
"github.com/HFO4/cloudreve/pkg/crontab"
"github.com/HFO4/cloudreve/pkg/email"
"github.com/HFO4/cloudreve/pkg/task"
"github.com/HFO4/cloudreve/routers"
"github.com/gin-gonic/gin"
......@@ -24,6 +26,8 @@ func init() {
authn.Init()
task.Init()
aria2.Init()
email.Init()
crontab.Init()
}
auth.Init()
}
......
......@@ -48,7 +48,7 @@ func CurrentUser() gin.HandlerFunc {
session := sessions.Default(c)
uid := session.Get("user_id")
if uid != nil {
user, err := model.GetUserByID(uid)
user, err := model.GetActiveUserByID(uid)
if err == nil {
c.Set("user", &user)
}
......@@ -135,7 +135,7 @@ func uploadCallbackCheck(c *gin.Context) (serializer.Response, *model.User) {
_ = cache.Deletes([]string{callbackKey}, "callback_")
// 查找用户
user, err := model.GetUserByID(callbackSession.UID)
user, err := model.GetActiveUserByID(callbackSession.UID)
if err != nil {
return serializer.Err(serializer.CodeCheckLogin, "找不到用户", err), nil
}
......
......@@ -89,6 +89,7 @@ func addDefaultSettings() {
{Name: "siteDes", Value: `Cloudreve`, Type: "basic"},
{Name: "siteTitle", Value: `平步云端`, Type: "basic"},
{Name: "fromName", Value: `Cloudreve`, Type: "mail"},
{Name: "mail_keepalive", Value: `30`, Type: "mail"},
{Name: "fromAdress", Value: `no-reply@acg.blue`, Type: "mail"},
{Name: "smtpHost", Value: `smtp.mxhichina.com`, Type: "mail"},
{Name: "smtpPort", Value: `25`, Type: "mail"},
......@@ -151,9 +152,7 @@ Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; verti
{Name: "admin_color_body", Value: `fixed-nav sticky-footer bg-light`, Type: "admin"},
{Name: "admin_color_nav", Value: `navbar navbar-expand-lg fixed-top navbar-light bg-light`, Type: "admin"},
{Name: "js_code", Value: `<script type="text/javascript"></script>`, Type: "basic"},
{Name: "sendfile", Value: `0`, Type: "download"},
{Name: "defaultTheme", Value: `#3f51b5`, Type: "basic"},
{Name: "header", Value: `X-Sendfile`, Type: "download"},
{Name: "themes", Value: `{"#3f51b5":{"palette":{"primary":{"light":"#7986cb","main":"#3f51b5","dark":"#303f9f","contrastText":"#fff"},"secondary":{"light":"#ff4081","main":"#f50057","dark":"#c51162","contrastText":"#fff"},"error":{"light":"#e57373","main":"#f44336","dark":"#d32f2f","contrastText":"#fff"},"explorer":{"filename":"#474849","icon":"#8f8f8f","bgSelected":"#D5DAF0","emptyIcon":"#e8e8e8"}}}}`, Type: "basic"},
{Name: "refererCheck", Value: `true`, Type: "share"},
{Name: "header", Value: `X-Sendfile`, Type: "download"},
......@@ -170,6 +169,9 @@ Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; verti
{Name: "share_score_rate", Value: "80", Type: "score"},
{Name: "home_view_method", Value: "icon", Type: "view"},
{Name: "share_view_method", Value: "list", Type: "view"},
{Name: "cron_garbage_collect", Value: "@hourly", Type: "cron"},
{Name: "cron_notify_user", Value: "@hourly", Type: "cron"},
{Name: "cron_ban_user", Value: "@hourly", Type: "cron"},
}
for _, value := range defaultSettings {
......@@ -241,7 +243,6 @@ func addDefaultUser() {
defaultUser.Nick = "admin"
defaultUser.Status = Active
defaultUser.GroupID = 1
defaultUser.PrimaryGroup = 1
err := defaultUser.SetPassword("admin")
if err != nil {
util.Log().Panic("无法创建密码, %s", err)
......
......@@ -59,3 +59,15 @@ func (user *User) GetAvailablePackSize() uint64 {
return total
}
// GetExpiredStoragePack 获取已过期的容量包
func GetExpiredStoragePack() []StoragePack {
var packs []StoragePack
DB.Where("expired_time < ?", time.Now()).Find(&packs)
return packs
}
// Delete 删除容量包
func (pack *StoragePack) Delete() error {
return DB.Delete(&pack).Error
}
......@@ -26,22 +26,23 @@ const (
type User struct {
// 表字段
gorm.Model
Email string `gorm:"type:varchar(100);unique_index"`
Nick string `gorm:"size:50"`
Password string `json:"-"`
Status int
GroupID uint
PrimaryGroup int
ActivationKey string `json:"-"`
Storage uint64
LastNotify *time.Time
OpenID string `json:"-"`
TwoFactor string `json:"-"`
Delay int
Avatar string
Options string `json:"-",gorm:"type:text"`
Authn string `gorm:"type:text"`
Score int
Email string `gorm:"type:varchar(100);unique_index"`
Nick string `gorm:"size:50"`
Password string `json:"-"`
Status int
GroupID uint
ActivationKey string `json:"-"`
Storage uint64
OpenID string `json:"-"`
TwoFactor string `json:"-"`
Delay int
Avatar string
Options string `json:"-",gorm:"type:text"`
Authn string `gorm:"type:text"`
Score int
PreviousGroupID uint // 初始用户组
GroupExpires *time.Time // 用户组过期日期
NotifyDate *time.Time // 通知超出配额时的日期
// 关联模型
Group Group `gorm:"association_autoupdate:false"`
......@@ -165,6 +166,13 @@ func GetUserByID(ID interface{}) (User, error) {
return user, result.Error
}
// GetActiveUserByID 用ID获取可登录用户
func GetActiveUserByID(ID interface{}) (User, error) {
var user User
result := DB.Set("gorm:auto_preload", true).Where("status = ?", Active).First(&user, ID)
return user, result.Error
}
// GetUserByEmail 用Email获取用户
func GetUserByEmail(email string) (User, error) {
var user User
......@@ -271,3 +279,49 @@ func NewAnonymousUser() *User {
func (user *User) IsAnonymous() bool {
return user.ID == 0
}
// Notified 更新用户容量超额通知日期
func (user *User) Notified() {
if user.NotifyDate == nil {
timeNow := time.Now()
user.NotifyDate = &timeNow
DB.Model(&user).Update("notify_date", user.NotifyDate)
}
}
// ClearNotified 清除用户通知标记
func (user *User) ClearNotified() {
DB.Model(&user).Update("notify_date", nil)
}
// SetStatus 设定用户状态
func (user *User) SetStatus(status int) {
DB.Model(&user).Update("status", status)
}
// GetGroupExpiredUsers 获取用户组过期的用户
func GetGroupExpiredUsers() []User {
var users []User
DB.Where("group_expires < ? and previous_group_id <> 0", time.Now()).Find(&users)
return users
}
// GetTolerantExpiredUser 获取超过宽容期的用户
func GetTolerantExpiredUser() []User {
var users []User
DB.Set("gorm:auto_preload", true).Where("notify_date < ?", time.Now().Add(
time.Duration(-GetIntSetting("ban_time", 10))*time.Second),
).Find(&users)
return users
}
// GroupFallback 回退到初始用户组
func (user *User) GroupFallback() {
if user.GroupExpires != nil && user.PreviousGroupID != 0 {
DB.Model(&user).Updates(map[string]interface{}{
"group_expires": nil,
"previous_group_id": 0,
"group_id": user.PreviousGroupID,
})
}
}
package cache
import (
"github.com/HFO4/cloudreve/pkg/util"
"sync"
"time"
)
......@@ -46,6 +47,19 @@ func getValue(item interface{}, ok bool) (interface{}, bool) {
}
// GarbageCollect 回收已过期的缓存
func (store *MemoStore) GarbageCollect() {
store.Store.Range(func(key, value interface{}) bool {
if item, ok := value.(itemWithTTL); ok {
if item.expires > 0 && item.expires < time.Now().Unix() {
util.Log().Debug("回收垃圾[%s]", key.(string))
store.Store.Delete(key)
}
}
return true
})
}
// NewMemoStore 新建内存存储
func NewMemoStore() *MemoStore {
return &MemoStore{
......
package crontab
import (
model "github.com/HFO4/cloudreve/models"
"github.com/HFO4/cloudreve/pkg/cache"
"github.com/HFO4/cloudreve/pkg/util"
"os"
"path/filepath"
"strings"
"time"
)
func garbageCollect() {
// 清理打包下载产生的临时文件
collectArchiveFile()
// 清理过期的内置内存缓存
if store, ok := cache.Store.(*cache.MemoStore); ok {
collectCache(store)
}
util.Log().Info("定时任务 [cron_garbage_collect] 执行完毕")
}
func collectArchiveFile() {
// 读取有效期、目录设置
tempPath := model.GetSettingByName("temp_path")
expires := model.GetIntSetting("download_timeout", 30)
// 列出文件
root := filepath.Join(tempPath, "archive")
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if !info.IsDir() &&
strings.HasPrefix(filepath.Base(path), "archive_") &&
time.Now().Sub(info.ModTime()).Seconds() > float64(expires) {
util.Log().Debug("删除过期打包下载临时文件 [%s]", path)
// 删除符合条件的文件
if err := os.Remove(path); err != nil {
util.Log().Debug("临时文件 [%s] 删除失败 , %s", path, err)
}
}
return nil
})
if err != nil {
util.Log().Debug("[定时任务] 无法列取临时打包目录")
}
}
func collectCache(store *cache.MemoStore) {
util.Log().Debug("清理内存缓存")
store.GarbageCollect()
}
package crontab
import (
model "github.com/HFO4/cloudreve/models"
"github.com/HFO4/cloudreve/pkg/util"
"github.com/robfig/cron/v3"
)
// Cron 定时任务
var Cron *cron.Cron
// Reload 重新启动定时任务
func Reload() {
if Cron != nil {
Cron.Stop()
}
Init()
}
// Init 初始化定时任务
func Init() {
util.Log().Info("初始化定时任务...")
// 读取cron日程设置
options := model.GetSettingByNames("cron_garbage_collect", "cron_notify_user", "cron_ban_user")
Cron := cron.New()
for k, v := range options {
var handler func()
switch k {
case "cron_garbage_collect":
handler = garbageCollect
case "cron_notify_user":
handler = notifyExpiredVAS
case "cron_ban_user":
handler = banOverusedUser
default:
util.Log().Warning("未知定时任务类型 [%s],跳过", k)
continue
}
if _, err := Cron.AddFunc(v, handler); err != nil {
util.Log().Warning("无法启动定时任务 [%s] , %s", k, err)
}
}
banOverusedUser()
Cron.Start()
}
package crontab
import (
model "github.com/HFO4/cloudreve/models"
"github.com/HFO4/cloudreve/pkg/email"
"github.com/HFO4/cloudreve/pkg/util"
)
func notifyExpiredVAS() {
checkStoragePack()
checkUserGroup()
util.Log().Info("定时任务 [cron_notify_user] 执行完毕")
}
// banOverusedUser 封禁超出宽容期的用户
func banOverusedUser() {
users := model.GetTolerantExpiredUser()
for _, user := range users {
// 清除最后通知日期标记
user.ClearNotified()
// 检查容量是否超额
if user.Storage > user.Group.MaxStorage+user.GetAvailablePackSize() {
// 封禁用户
user.SetStatus(model.OveruseBaned)
}
}
}
// checkUserGroup 检查已过期用户组
func checkUserGroup() {
users := model.GetGroupExpiredUsers()
for _, user := range users {
// 将用户回退到初始用户组
user.GroupFallback()
// 重新加载用户
user, _ = model.GetUserByID(user.ID)
// 检查容量是否超额
if user.Storage > user.Group.MaxStorage+user.GetAvailablePackSize() {
// 如果超额,则通知用户
sendNotification(&user, "用户组过期")
// 更新最后通知日期
user.Notified()
}
}
}
// checkStoragePack 检查已过期的容量包
func checkStoragePack() {
packs := model.GetExpiredStoragePack()
for _, pack := range packs {
//找到所属用户
user, err := model.GetUserByID(pack.ID)
if err != nil {
util.Log().Warning("[定时任务] 无法获取用户 [UID=%d] 信息, %s", pack.ID, err)
continue
}
// 检查容量是否超额
if user.Storage > user.Group.MaxStorage+user.GetAvailablePackSize() {
// 如果超额,则通知用户
sendNotification(&user, "容量包过期")
// 删除过期的容量包
pack.Delete()
// 更新最后通知日期
user.Notified()
}
}
}
func sendNotification(user *model.User, reason string) {
title, body := email.NewOveruseNotification(user.Nick, reason)
if err := email.Send(user.Email, title, body); err != nil {
util.Log().Warning("无法发送通知邮件, %s", err)
}
}
package email
import model "github.com/HFO4/cloudreve/models"
// Client 默认的邮件发送客户端
var Client Driver
// Init 初始化
func Init() {
if Client != nil {
Client.Close()
}
// 读取SMTP设置
options := model.GetSettingByNames(
"fromName",
"fromAdress",
"smtpHost",
"replyTo",
"smtpUser",
"smtpPass",
)
port := model.GetIntSetting("smtpPort", 25)
keepAlive := model.GetIntSetting("mail_keepalive", 30)
client := NewSMTPClient(SMTPConfig{
Name: options["fromName"],
Address: options["fromAdress"],
ReplyTo: options["replyTo"],
Host: options["smtpHost"],
Port: port,
User: options["smtpUser"],
Password: options["smtpPass"],
Keepalive: keepAlive,
})
Client = client
}
package email
import "errors"
// Driver 邮件发送驱动
type Driver interface {
// Close 关闭驱动
Close()
// Send 发送邮件
Send(to, title, body string) error
}
var (
// ErrChanNotOpen 邮件队列未开启
ErrChanNotOpen = errors.New("邮件队列未开启")
// ErrNoActiveDriver 无可用邮件发送服务
ErrNoActiveDriver = errors.New("无可用邮件发送服务")
)
// Send 发送邮件
func Send(to, title, body string) error {
if Client == nil {
return ErrNoActiveDriver
}
return Client.Send(to, title, body)
}
package email
import (
"github.com/HFO4/cloudreve/pkg/util"
"github.com/go-mail/mail"
"time"
)
// SMTP SMTP协议发送邮件
type SMTP struct {
Config SMTPConfig
ch chan *mail.Message
chOpen bool
}
// SMTPConfig SMTP发送配置
type SMTPConfig struct {
Name string // 发送者名
Address string // 发送者地址
ReplyTo string // 回复地址
Host string // 服务器主机名
Port int // 服务器端口
User string // 用户名
Password string // 密码
Encryption string // 是否启用加密
Keepalive int // SMTP 连接保留时长
}
// NewSMTPClient 新建SMTP发送队列
func NewSMTPClient(config SMTPConfig) *SMTP {
client := &SMTP{
Config: config,
ch: make(chan *mail.Message, 30),
chOpen: false,
}
client.Init()
return client
}
// Send 发送邮件
func (client *SMTP) Send(to, title, body string) error {
if !client.chOpen {
return ErrChanNotOpen
}
m := mail.NewMessage()
m.SetHeader("From", client.Config.Address)
m.SetHeader("To", to)
m.SetHeader("Subject", title)
m.SetBody("text/html", body)
client.ch <- m
return nil
}
// Close 关闭发送队列
func (client *SMTP) Close() {
if client.ch != nil {
close(client.ch)
}
}
// Init 初始化发送队列
func (client *SMTP) Init() {
go func() {
defer func() {
if err := recover(); err != nil {
client.chOpen = false
util.Log().Error("邮件发送队列出现异常, %s ,30 秒后重新连接", err)
time.Sleep(time.Duration(30) * time.Second)
client.Init()
}
}()
d := mail.NewDialer(client.Config.Host, client.Config.Port, client.Config.User, client.Config.Password)
d.Timeout = time.Duration(client.Config.Keepalive+5) * time.Second
client.chOpen = true
var s mail.SendCloser
var err error
open := false
for {
select {
case m, ok := <-client.ch:
if !ok {
client.chOpen = false
return
}
if !open {
if s, err = d.Dial(); err != nil {
panic(err)
}
open = true
}
if err := mail.Send(s, m); err != nil {
util.Log().Warning("邮件发送失败, %s", err)
} else {
util.Log().Debug("邮件已发送")
}
// 长时间没有新邮件,则关闭SMTP连接
case <-time.After(time.Duration(client.Config.Keepalive) * time.Second):
if open {
if err := s.Close(); err != nil {
util.Log().Warning("无法关闭 SMTP 连接 %s", err)
}
open = false
}
}
}
}()
}
package email
import (
"fmt"
model "github.com/HFO4/cloudreve/models"
"github.com/HFO4/cloudreve/pkg/util"
)
// NewOveruseNotification 新建超额提醒邮件
func NewOveruseNotification(userName, reason string) (string, string) {
options := model.GetSettingByNames("siteName", "siteURL", "siteTitle", "over_used_template")
replace := map[string]string{
"{siteTitle}": options["siteName"],
"{userName}": userName,
"{notifyReason}": reason,
"{siteUrl}": options["siteURL"],
"{siteSecTitle}": options["siteTitle"],
}
return fmt.Sprintf("【%s】空间容量超额提醒", options["siteName"]),
util.Replace(replace, options["over_used_template"])
}
......@@ -115,6 +115,7 @@ func HookResetPolicy(ctx context.Context, fs *FileSystem) error {
}
fs.Policy = originFile.GetPolicy()
fs.User.Policy = *fs.Policy
return fs.DispatchHandler()
}
......
......@@ -137,7 +137,7 @@ func NewCompressTask(user *model.User, dst string, dirs, files []uint) (Job, err
// NewCompressTaskFromModel 从数据库记录中恢复压缩任务
func NewCompressTaskFromModel(task *model.Task) (Job, error) {
user, err := model.GetUserByID(task.UserID)
user, err := model.GetActiveUserByID(task.UserID)
if err != nil {
return nil, err
}
......
......@@ -109,7 +109,7 @@ func NewDecompressTask(user *model.User, src, dst string) (Job, error) {
// NewDecompressTaskFromModel 从数据库记录中恢复压缩任务
func NewDecompressTaskFromModel(task *model.Task) (Job, error) {
user, err := model.GetUserByID(task.UserID)
user, err := model.GetActiveUserByID(task.UserID)
if err != nil {
return nil, err
}
......
......@@ -109,7 +109,7 @@ func (job *TransferTask) Recycle() {
// NewTransferTask 新建中转任务
func NewTransferTask(user uint, src []string, dst, parent string) (Job, error) {
creator, err := model.GetUserByID(user)
creator, err := model.GetActiveUserByID(user)
if err != nil {
return nil, err
}
......@@ -134,7 +134,7 @@ func NewTransferTask(user uint, src []string, dst, parent string) (Job, error) {
// NewTransferTaskFromModel 从数据库记录中恢复中转任务
func NewTransferTaskFromModel(task *model.Task) (Job, error) {
user, err := model.GetUserByID(task.UserID)
user, err := model.GetActiveUserByID(task.UserID)
if err != nil {
return nil, err
}
......
......@@ -344,8 +344,8 @@ func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request, fs *filesyst
fs.Use("AfterValidateFailed", filesystem.HookUpdateSourceName)
}
fs.Use("BeforeUpload", filesystem.HookValidateFile)
fs.Use("BeforeUpload", filesystem.HookResetPolicy)
fs.Use("BeforeUpload", filesystem.HookValidateFile)
fs.Use("BeforeUpload", filesystem.HookChangeCapacity)
fs.Use("AfterUploadCanceled", filesystem.HookCleanFileContent)
fs.Use("AfterUploadCanceled", filesystem.HookClearFileSize)
......
......@@ -338,8 +338,8 @@ func (service *FileIDService) PutContent(ctx context.Context, c *gin.Context) se
}
// 给文件系统分配钩子
fs.Use("BeforeUpload", filesystem.HookValidateFile)
fs.Use("BeforeUpload", filesystem.HookResetPolicy)
fs.Use("BeforeUpload", filesystem.HookValidateFile)
fs.Use("BeforeUpload", filesystem.HookChangeCapacity)
fs.Use("AfterUploadCanceled", filesystem.HookCleanFileContent)
fs.Use("AfterUploadCanceled", filesystem.HookClearFileSize)
......
......@@ -36,7 +36,7 @@ func (service *UserLoginService) Login(c *gin.Context) serializer.Response {
if authOK, _ := expectedUser.CheckPassword(service.Password); !authOK {
return serializer.Err(401, "用户邮箱或密码错误", nil)
}
if expectedUser.Status == model.Baned {
if expectedUser.Status == model.Baned || expectedUser.Status == model.OveruseBaned {
return serializer.Err(403, "该账号已被封禁", nil)
}
if expectedUser.Status == model.NotActivicated {
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册