fix login error

This commit is contained in:
tamakyi
2026-09-15 13:10:57 +08:00
parent 0e0c16f786
commit 5ee2dc1540
14 changed files with 801 additions and 68 deletions
+123
View File
@@ -0,0 +1,123 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package cmd
import (
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
)
// Restore 恢复被自助停用的账号。
//
// 自助停用(Deactivate)只清空内容、保留用户行作「身份占位」(软删除),
// 因此恢复只需清掉 deleted_at;内容已不可找回。
//
// 用法:TamaBox restore --email [email protected]
//
// TamaBox restore --domain someone --password 'new-password'
var Restore = &cli.Command{
Name: "restore",
Usage: "Restore a deactivated account (clears the identity placeholder)",
Action: runRestore,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "email",
Usage: "Email of the deactivated account",
},
&cli.StringFlag{
Name: "domain",
Usage: "Custom domain of the deactivated account",
},
&cli.StringFlag{
Name: "password",
Usage: "Optional: reset the password to this value (min 8 chars)",
},
&cli.BoolFlag{
Name: "yes",
Usage: "Skip confirmation",
},
},
}
func runRestore(ctx *cli.Context) error {
if err := conf.Init(); err != nil {
return errors.Wrap(err, "load configuration")
}
dbType := conf.Database.Type
if dbType == "postgres" {
conf.Database.DSN = conf.PostgresDsn()
} else {
conf.Database.DSN = conf.MySQLDsn()
}
database, err := db.Init(dbType, conf.Database.DSN)
if err != nil {
return errors.Wrap(err, "connect to database")
}
email := ctx.String("email")
domain := ctx.String("domain")
if email == "" && domain == "" {
return errors.New("must provide --email or --domain")
}
newPassword := ctx.String("password")
if newPassword != "" && len(newPassword) < 8 {
return errors.New("password must be at least 8 characters")
}
// 用 Unscoped 查找:待恢复的账号必然是软删除状态,普通查询看不见
var user db.User
query := database.WithContext(ctx.Context).Unscoped()
if email != "" {
query = query.Where("email = ?", email)
} else {
query = query.Where("domain = ?", domain)
}
if err := query.First(&user).Error; err != nil {
return errors.Wrap(err, "find user to restore")
}
if !user.DeletedAt.Valid {
return errors.Errorf("账号 %q 未被停用,无需恢复", user.Domain)
}
logrus.WithFields(logrus.Fields{
"id": user.ID,
"name": user.Name,
"email": user.Email,
"domain": user.Domain,
}).Warn("准备恢复以下已停用账号(提问/追问/私聊等内容在停用时已被删除,无法找回)")
if !ctx.Bool("yes") {
logrus.Warn("输入大写 YES 确认执行恢复:")
var answer string
if _, err := fmtScan(&answer); err != nil {
return errors.Wrap(err, "read confirmation")
}
if answer != "YES" {
return errors.New("已取消")
}
}
if err := db.Users.Reactivate(ctx.Context, user.ID); err != nil {
return errors.Wrap(err, "reactivate user")
}
// 密码重置必须在恢复之后:UpdatePassword 走普通作用域,软删除状态下找不到该用户
if newPassword != "" {
if err := db.Users.UpdatePassword(ctx.Context, user.ID, newPassword); err != nil {
return errors.Wrap(err, "reset password")
}
logrus.WithField("user_id", user.ID).Info("账号已恢复,密码已重置")
return nil
}
logrus.WithField("user_id", user.ID).Info("账号已恢复(密码未改动,如原主已忘记密码请用 --password 重置)")
return nil
}
+148 -68
View File
@@ -71,6 +71,8 @@ type UsersStore interface {
CountTodayMessagesFromUser(ctx context.Context, ownerID, askerID uint) (int64, error)
UpdateDailyMessageLimit(ctx context.Context, id uint, limit int) error
BanUserAndCleanup(ctx context.Context, userID uint) error
// Reactivate 恢复被自助停用的账号(内容已清空),供站长核验身份后调用
Reactivate(ctx context.Context, id uint) error
}
func NewUsersStore(db *gorm.DB) UsersStore {
@@ -226,6 +228,11 @@ var (
ErrBadCredential = errors.New("邮箱或密码错误")
ErrDuplicateEmail = errors.New("这个邮箱已经注册过账号了!")
ErrDuplicateDomain = errors.New("个性域名重复了,换一个吧~")
// 已停用(软删除)账号仍占用 email / domain 唯一索引:gorm 的普通查重看不见它,
// 必须 Unscoped 才能识别,否则会退化成「INSERT 撞唯一索引 → 500」。
ErrDeactivatedEmail = errors.New("该邮箱对应的账号已停用")
ErrDeactivatedDomain = errors.New("该个性域名对应的账号已停用")
)
func (db *users) Create(ctx context.Context, opts CreateUserOptions) error {
@@ -507,38 +514,94 @@ func (db *users) UpdatePassword(ctx context.Context, id uint, newPassword string
return nil
}
// Deactivate 自助停用账号:删除该账号的全部内容数据,保留一行「身份占位」(软删除)。
//
// 为什么保留用户行(软删除)而不是物理删除:
// users 的 email / domain 是唯一索引。若连行一起删掉,这个邮箱/域名就能被任何人立刻重新注册;
// 而停用的语义是「该身份归原主所有」——留占位既是防止他人抢占,也让站长核验后能把账号恢复成空账号。
// ⚠️ 代价是普通查重看不见这一行,注册侧必须用 Unscoped 识别(见 validate 与 ErrDeactivated*),
// 否则会退化成「查重放行、INSERT 撞唯一索引 → 500」。
func (db *users) Deactivate(ctx context.Context, id uint) error {
u, err := db.GetByID(ctx, id)
if err != nil {
return errors.Wrap(err, "get user by id")
}
if err := db.WithContext(ctx).Model(&User{}).Delete("id = ?", u.ID).Error; err != nil {
return errors.Wrap(err, "delete user")
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := purgeUserContent(ctx, tx, u.ID); err != nil {
return err
}
// 内容已清空,用户行只作身份占位保留 → 软删除(写 deleted_at)
if err := tx.WithContext(ctx).Delete(&User{}, u.ID).Error; err != nil {
return errors.Wrap(err, "deactivate user")
}
return nil
})
}
// Reactivate 恢复被自助停用的账号(内容已被清空的空账号),供站长核验身份后调用。
// 密码不会被重置,若原主已忘记密码,可再用 UpdatePassword 另行处理。
func (db *users) Reactivate(ctx context.Context, id uint) error {
result := db.WithContext(ctx).Unscoped().Model(&User{}).
Where("id = ? AND deleted_at IS NOT NULL", id).
Update("deleted_at", nil)
if result.Error != nil {
return errors.Wrap(result.Error, "reactivate user")
}
if result.RowsAffected == 0 {
return ErrUserNotExists
}
return nil
}
// validate 检查邮箱与个性域名是否可用。
//
// 分两轮:先看活跃账号(普通占用),再看「已停用」的软删除占位行。
// 第二轮必须 Unscoped —— 停用会保留用户行作为身份占位,普通查询看不见它,
// 但 email / domain 的唯一索引仍会拦下 INSERT,于是表现为「查重放行、插入 500」。
func (db *users) validate(ctx context.Context, opts CreateUserOptions) error {
if err := db.WithContext(ctx).Model(&User{}).Where("email = ?", opts.Email).First(&User{}).Error; err != nil {
if err != gorm.ErrRecordNotFound {
return errors.Wrap(err, "validate email")
}
} else {
if taken, err := db.fieldTaken(ctx, "email", opts.Email, false); err != nil {
return errors.Wrap(err, "validate email")
} else if taken {
return ErrDuplicateEmail
}
if err := db.WithContext(ctx).Model(&User{}).Where("domain = ?", opts.Domain).First(&User{}).Error; err != nil {
if err != gorm.ErrRecordNotFound {
return errors.Wrap(err, "validate name")
}
} else {
if taken, err := db.fieldTaken(ctx, "domain", opts.Domain, false); err != nil {
return errors.Wrap(err, "validate domain")
} else if taken {
return ErrDuplicateDomain
}
if taken, err := db.fieldTaken(ctx, "email", opts.Email, true); err != nil {
return errors.Wrap(err, "validate deactivated email")
} else if taken {
return ErrDeactivatedEmail
}
if taken, err := db.fieldTaken(ctx, "domain", opts.Domain, true); err != nil {
return errors.Wrap(err, "validate deactivated domain")
} else if taken {
return ErrDeactivatedDomain
}
return nil
}
// fieldTaken 判断某个唯一字段是否已被占用。
// includeDeleted 为 true 时连已停用(软删除)的占位行一起计入。
// column 只允许由调用方传入字面量(email / domain),不接受外部输入。
func (db *users) fieldTaken(ctx context.Context, column, value string, includeDeleted bool) (bool, error) {
query := db.WithContext(ctx).Model(&User{})
if includeDeleted {
query = query.Unscoped()
}
var count int64
if err := query.Where(column+" = ?", value).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
type UpdateTagsOptions struct {
Tags string
ShowInHome bool
@@ -902,68 +965,85 @@ func (db *users) UpdateDailyMessageLimit(ctx context.Context, id uint, limit int
// BanUserAndCleanup 封禁用户并连带清理其全部数据(提问、追问、私聊、消息、图片关联),
// 最后物理删除用户记录。供管理 CLI 命令(tamabox ban)使用。
//
// 与自助停用(Deactivate)的区别只有一处:封禁连用户行一起物理删除,
// 因此该邮箱/域名会被彻底释放、可被任何人重新注册。
//
// 注意:该操作不可恢复,执行前请确认目标账号。
func (db *users) BanUserAndCleanup(ctx context.Context, userID uint) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 追问:属于该用户提问下的追问
if err := tx.WithContext(ctx).
Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID).
Delete(&FollowUp{}).Error; err != nil {
return errors.Wrap(err, "delete follow ups of banned user")
if err := purgeUserContent(ctx, tx, userID); err != nil {
return err
}
// 消息:属于该用户作为 owner 或 asker 的会话
if err := tx.WithContext(ctx).
Where("conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?)", userID, userID).
Delete(&Message{}).Error; err != nil {
return errors.Wrap(err, "delete messages of banned user")
}
// 消息图片
if err := tx.WithContext(ctx).
Where("message_id IN (SELECT id FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?))", userID, userID).
Delete(&MessageImage{}).Error; err != nil {
return errors.Wrap(err, "delete message images of banned user")
}
// 私聊会话
if err := tx.WithContext(ctx).
Where("owner_id = ? OR asker_id = ?", userID, userID).
Delete(&Conversation{}).Error; err != nil {
return errors.Wrap(err, "delete conversations of banned user")
}
// 提问-图片关联(仅删除关联记录;UploadImage 本体为通用资源,保留)
if err := tx.WithContext(ctx).
Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID).
Delete(&UploadImageQuestion{}).Error; err != nil {
return errors.Wrap(err, "delete upload image links of banned user")
}
// 提问
if err := tx.WithContext(ctx).
Where("user_id = ?", userID).
Delete(&Question{}).Error; err != nil {
return errors.Wrap(err, "delete questions of banned user")
}
// 拉黑关系(该用户拉黑别人,或被别人拉黑)
if err := tx.WithContext(ctx).
Where("user_id = ? OR blocked_id = ?", userID, userID).
Delete(&UserBlock{}).Error; err != nil {
return errors.Wrap(err, "delete user blocks of banned user")
}
// 功能开关配置(提问 / 私聊 / 投票)
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ?", userID).
Delete(&FeatureControl{}).Error; err != nil {
return errors.Wrap(err, "delete feature controls of banned user")
}
// 物理删除用户
if err := tx.WithContext(ctx).Unscoped().Delete(&User{}, userID).Error; err != nil {
return errors.Wrap(err, "delete banned user")
return errors.Wrap(err, "delete user")
}
return nil
})
}
// purgeUserContent 物理删除某用户的全部内容数据:追问、消息与消息图片、私聊会话、
// 提问-图片关联、提问、拉黑关系、功能开关。**不含用户行本身**(由调用方决定是软删除还是物理删除)。
//
// 全部使用 Unscoped 物理删除而非软删除:这些表多为软删除模型,而部分表的唯一索引
// 不含 deleted_at(如 conversations.token),软删除会让被删行永久占据唯一槽位。
//
// ⚠️ 每条语句都必须经 tx.WithContext(ctx) 取新句柄:事务句柄的 clone 为 0,
// 直接复用会把上一条语句的 Where 条件串到下一条上(曾把 messages 的条件串进 follow_ups,
// 报「字段 conversation_id 不存在」)。
func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
// 追问:属于该用户提问下的追问
if err := tx.WithContext(ctx).Unscoped().
Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID).
Delete(&FollowUp{}).Error; err != nil {
return errors.Wrap(err, "delete follow ups of user")
}
// 消息:属于该用户作为 owner 或 asker 的会话
if err := tx.WithContext(ctx).Unscoped().
Where("conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?)", userID, userID).
Delete(&Message{}).Error; err != nil {
return errors.Wrap(err, "delete messages of user")
}
// 消息图片
if err := tx.WithContext(ctx).Unscoped().
Where("message_id IN (SELECT id FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?))", userID, userID).
Delete(&MessageImage{}).Error; err != nil {
return errors.Wrap(err, "delete message images of user")
}
// 私聊会话
if err := tx.WithContext(ctx).Unscoped().
Where("owner_id = ? OR asker_id = ?", userID, userID).
Delete(&Conversation{}).Error; err != nil {
return errors.Wrap(err, "delete conversations of user")
}
// 提问-图片关联(仅删除关联记录;UploadImage 本体为通用资源,保留)
if err := tx.WithContext(ctx).Unscoped().
Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID).
Delete(&UploadImageQuestion{}).Error; err != nil {
return errors.Wrap(err, "delete upload image links of user")
}
// 提问
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ?", userID).
Delete(&Question{}).Error; err != nil {
return errors.Wrap(err, "delete questions of user")
}
// 拉黑关系(该用户拉黑别人,或被别人拉黑)
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ? OR blocked_id = ?", userID, userID).
Delete(&UserBlock{}).Error; err != nil {
return errors.Wrap(err, "delete user blocks of user")
}
// 功能开关配置(提问 / 私聊 / 投票)
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ?", userID).
Delete(&FeatureControl{}).Error; err != nil {
return errors.Wrap(err, "delete feature controls of user")
}
return nil
}
+115
View File
@@ -6,6 +6,7 @@ package db
import (
"context"
"errors"
"testing"
"time"
@@ -470,3 +471,117 @@ func TestHarassmentSettingNormalize(t *testing.T) {
require.Nil(t, err)
require.Equal(t, HarassmentSettingNone, got.HarassmentSetting, "历史空值应被归一为 none")
}
// TestDeactivatePurgesUser 覆盖自助停用的删除语义。
//
// 停用 = 清空内容 + 保留用户行作「身份占位」(软删除),因此:
// - 该账号的提问等内容必须被物理删除;
// - 用户行必须仍在(Unscoped 可见,deleted_at 有效),邮箱/域名继续被占用 → 防止他人抢占;
// - 用同一邮箱/域名重新注册必须得到「账号已停用」的明确错误,而不是底层的唯一键 500;
// - 站长可通过 Reactivate 把账号恢复成空账号。
func TestDeactivatePurgesUser(t *testing.T) {
db, cleanup := newTestDB(t)
defer func() { _ = cleanup("users", "questions", "feature_controls") }()
ctx := context.Background()
usersStore := NewUsersStore(db)
questionsStore := NewQuestionsStore(db)
opts := CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "purge",
Background: "background.png",
Intro: "Be cool, but also be warm.",
}
require.Nil(t, usersStore.Create(ctx, opts))
user, err := usersStore.GetByEmail(ctx, opts.Email)
require.Nil(t, err)
_, err = questionsStore.Create(ctx, CreateQuestionOptions{UserID: user.ID, Content: "hello"})
require.Nil(t, err)
require.Nil(t, usersStore.Deactivate(ctx, user.ID))
var n int64
// 身份占位必须保留(Unscoped 可见),且处于软删除状态
var placeholder User
require.Nil(t, db.WithContext(ctx).Unscoped().Where("email = ?", opts.Email).First(&placeholder).Error)
require.True(t, placeholder.DeletedAt.Valid, "停用后用户行应保留为软删除的身份占位")
require.Equal(t, user.ID, placeholder.ID)
require.Nil(t, db.WithContext(ctx).Unscoped().Model(&Question{}).Where("user_id = ?", user.ID).Count(&n).Error)
require.EqualValues(t, 0, n, "停用后该用户的提问应被物理删除")
// 普通查询已看不见该占位行
_, err = usersStore.GetByEmail(ctx, opts.Email)
require.Equal(t, ErrUserNotExists, err)
// 同一邮箱 / 域名重新注册:给出明确的「已停用」错误,而不是底层唯一键冲突
err = usersStore.Create(ctx, opts)
require.True(t, errors.Is(err, ErrDeactivatedEmail), "同邮箱重注册应返回 ErrDeactivatedEmail,实际: %v", err)
require.False(t, errors.Is(err, ErrDuplicateEmail), "不应退化成普通重复邮箱错误")
err = usersStore.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: opts.Domain,
Background: "background.png",
Intro: "Be cool, but also be warm.",
})
require.True(t, errors.Is(err, ErrDeactivatedDomain), "同域名重注册应返回 ErrDeactivatedDomain,实际: %v", err)
// 站长恢复:占位行清掉 deleted_at,账号重新可见
require.Nil(t, usersStore.Reactivate(ctx, user.ID))
restored, err := usersStore.GetByEmail(ctx, opts.Email)
require.Nil(t, err)
require.Equal(t, user.ID, restored.ID)
require.False(t, restored.DeletedAt.Valid)
// 恢复后该邮箱已被活跃账号占用 → 普通重复错误
require.True(t, errors.Is(usersStore.Create(ctx, opts), ErrDuplicateEmail))
// 未被停用的账号无法重复恢复
require.Equal(t, ErrUserNotExists, usersStore.Reactivate(ctx, user.ID))
}
// TestBanUserAndCleanupRemovesRow 封禁与停用的唯一差别:封禁连用户行一起物理删除,
// 该邮箱/域名被彻底释放。
func TestBanUserAndCleanupRemovesRow(t *testing.T) {
db, cleanup := newTestDB(t)
defer func() { _ = cleanup("users", "questions", "feature_controls") }()
ctx := context.Background()
usersStore := NewUsersStore(db)
questionsStore := NewQuestionsStore(db)
opts := CreateUserOptions{
Name: "E99p1ant",
Password: "super_secret",
Email: "[email protected]",
Avatar: "avater.png",
Domain: "ban",
Background: "background.png",
Intro: "Be cool, but also be warm.",
}
require.Nil(t, usersStore.Create(ctx, opts))
user, err := usersStore.GetByEmail(ctx, opts.Email)
require.Nil(t, err)
_, err = questionsStore.Create(ctx, CreateQuestionOptions{UserID: user.ID, Content: "hello"})
require.Nil(t, err)
require.Nil(t, usersStore.BanUserAndCleanup(ctx, user.ID))
var n int64
require.Nil(t, db.WithContext(ctx).Unscoped().Model(&User{}).Where("email = ?", opts.Email).Count(&n).Error)
require.EqualValues(t, 0, n, "封禁后用户行应被物理删除")
require.Nil(t, db.WithContext(ctx).Unscoped().Model(&Question{}).Where("user_id = ?", user.ID).Count(&n).Error)
require.EqualValues(t, 0, n, "封禁后该用户的提问应被物理删除")
require.Nil(t, usersStore.Create(ctx, opts), "封禁后应能用同一邮箱重新注册")
}
+4
View File
@@ -418,6 +418,7 @@ var enDict = map[string]string{
"deactivate.desc": "After deactivation your question box will be inaccessible and all data will be deleted. This cannot be undone!",
"deactivate.confirm": "I confirm I want to deactivate my account",
"deactivate.submit": "Deactivate account",
"deactivate.note": "After deactivation, your email and custom domain are kept as an identity reservation to prevent others from taking them, so you will not be able to register again with the same email. To restore your account, please contact us through the \"Contact admin\" link in the page footer.",
// Deactivated page
"deactivated.title": "Account deactivated",
@@ -685,6 +686,9 @@ var enDict = map[string]string{
"auth.errLoginRequired": "Please log in first",
"auth.errDuplicateEmail": "This email is already registered",
"auth.errDuplicateDomain": "This custom domain is already taken",
// The deactivated account still holds this email/domain as an identity reservation
"auth.errDeactivatedEmail": "The account for this email has been deactivated. To use it again, please contact us through the \"Contact admin\" link in the page footer, using this email address.",
"auth.errDeactivatedDomain": "The account for this custom domain has been deactivated. To use it again, please contact us through the \"Contact admin\" link in the page footer.",
// Question box page: visitor-visible (P0)
"question.errWrongPassword": "Wrong password",
+4
View File
@@ -418,6 +418,7 @@ var jaDict = map[string]string{
"deactivate.desc": "停止後、あなたの質問箱にはアクセスできなくなり、すべてのデータが削除されます。この操作は取り消せません!",
"deactivate.confirm": "アカウントを停止することに同意します",
"deactivate.submit": "アカウントを停止",
"deactivate.note": "停止後、メールアドレスとカスタムドメインは第三者による取得を防ぐため本人確認用のプレースホルダーとして保持されるため、同じメールアドレスで再登録することはできません。復元をご希望の場合は、ページ下部の「管理者へ」からご連絡ください。",
// アカウント停止完了ページ
"deactivated.title": "アカウントを停止しました",
@@ -685,6 +686,9 @@ var jaDict = map[string]string{
"auth.errLoginRequired": "ログインしてください",
"auth.errDuplicateEmail": "このメールアドレスは既に登録されています",
"auth.errDuplicateDomain": "このカスタムドメインは既に使用されています",
// 停止済みアカウントがメールアドレス/ドメインを本人確認用に保持している
"auth.errDeactivatedEmail": "このメールアドレスのアカウントは停止されています。再びご利用になるには、ページ下部の「管理者へ」からこのメールアドレスでご連絡ください。",
"auth.errDeactivatedDomain": "このカスタムドメインのアカウントは停止されています。再びご利用になるには、ページ下部の「管理者へ」からご連絡ください。",
// 質問箱ページ:訪問者に表示(P0)
"question.errWrongPassword": "パスワードが正しくありません",
+4
View File
@@ -418,6 +418,7 @@ var zhCNDict = map[string]string{
"deactivate.desc": "停用后你的提问箱将无法访问,且所有数据将被删除,此操作不可恢复!",
"deactivate.confirm": "我确认要停用账号",
"deactivate.submit": "停用账号",
"deactivate.note": "停用后,你的邮箱与个性域名会作为身份占位保留(避免被他人占用),因此无法再用同一邮箱重新注册。如需恢复,请通过页脚的「找站长」入口联系我们。",
// 停用成功页
"deactivated.title": "账号已停用",
@@ -685,6 +686,9 @@ var zhCNDict = map[string]string{
"auth.errLoginRequired": "请先登录",
"auth.errDuplicateEmail": "该邮箱已被注册",
"auth.errDuplicateDomain": "该个性域名已被使用",
// 已停用账号仍占用邮箱/域名(身份占位),引导用户走站长核验恢复
"auth.errDeactivatedEmail": "该邮箱对应的账号已停用。如需重新使用,请通过页脚的「找站长」入口,用该邮箱与我们联系。",
"auth.errDeactivatedDomain": "该个性域名对应的账号已停用。如需重新使用,请通过页脚的「找站长」入口与我们联系。",
// 提问箱页:访客可见(P0)
"question.errWrongPassword": "密码错误",
+4
View File
@@ -418,6 +418,7 @@ var zhTWDict = map[string]string{
"deactivate.desc": "停用後你的提問箱將無法存取,且所有資料將被刪除,此操作不可恢復!",
"deactivate.confirm": "我確認要停用帳號",
"deactivate.submit": "停用帳號",
"deactivate.note": "停用後,你的郵箱與個性網域會作為身分佔位保留(避免被他人佔用),因此無法再用同一郵箱重新註冊。如需恢復,請透過頁腳的「找站長」入口與我們聯繫。",
// 停用成功頁
"deactivated.title": "帳號已停用",
@@ -685,6 +686,9 @@ var zhTWDict = map[string]string{
"auth.errLoginRequired": "請先登入",
"auth.errDuplicateEmail": "該郵箱已被註冊",
"auth.errDuplicateDomain": "該個性網域已被使用",
// 已停用帳號仍佔用郵箱/網域(身分佔位),引導使用者走站長核驗恢復
"auth.errDeactivatedEmail": "該郵箱對應的帳號已停用。如需重新使用,請透過頁腳的「找站長」入口,用該郵箱與我們聯繫。",
"auth.errDeactivatedDomain": "該個性網域對應的帳號已停用。如需重新使用,請透過頁腳的「找站長」入口與我們聯繫。",
// 提問箱頁:訪客可見(P0)
"question.errWrongPassword": "密碼錯誤",