一、密码规则:至少 8 位且不能全是数字。短信验证可以在后台关掉,关掉之后 密码就是账号质量的唯一门槛,而此前 validUserPassword 只检查非空——"1" 也是合法密码。规则只在「设置密码」时校验(注册、找回、改密、后台建号与 后台重置),登录不再校验,已有账号照常使用。上下限都按字符数计算,否则 43 个汉字的密码会因为字节数超限被拒。各处错误提示改为直接说明规则, 而不是笼统的一句「请填写有效的密码」。 二、离线推送:此前客户端一直在上报 push token,服务端从未下发过任何东西, App 退到后台或被杀掉时新消息完全没有提醒(MESSAGE_PUSH 只是 WebSocket 帧名)。补上服务端下发: - 只发给「此刻不在线 + 未对该会话免打扰 + 未关闭消息通知」的接收者, 在线的人已经从实时通道拿到了。 - 鉴权 token 按 provider 的过期时间缓存,个推的 auth 接口限流很紧。 - 失效的 cid(10001/10002)就地停用,不再每条消息重试一次。 - 整个过程在独立 goroutine 与独立 context 上进行,推送服务再慢也不会 拖慢或拖垮一条已经发出的消息。 - 是否显示正文由 push.show_preview 控制,关闭后锁屏上不出现消息内容。 凭据在管理端「离线推送」页填写(迁移 035 先建出配置行——集成配置保存 走的是 UPDATE,行不存在会静默保存不上)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
104 lines
4.1 KiB
Go
104 lines
4.1 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-sql-driver/mysql"
|
|
)
|
|
|
|
type adminCreateUserRequest struct {
|
|
Phone string `json:"phone"`
|
|
Password string `json:"password"`
|
|
Nickname string `json:"nickname"`
|
|
Gender int `json:"gender"`
|
|
City string `json:"city"`
|
|
Bio string `json:"bio"`
|
|
}
|
|
|
|
// Administrator provisioning uses the same credentials as public registration,
|
|
// but never creates a login session or marks the phone/identity as verified.
|
|
func (a *App) adminCreateUser(w http.ResponseWriter, r *http.Request) {
|
|
var req adminCreateUserRequest
|
|
if decode(r, &req) != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "用户资料格式不正确")
|
|
return
|
|
}
|
|
req.Phone = strings.TrimSpace(req.Phone)
|
|
req.Nickname = strings.TrimSpace(req.Nickname)
|
|
req.City = strings.TrimSpace(req.City)
|
|
req.Bio = strings.TrimSpace(req.Bio)
|
|
if !validPhone(req.Phone) {
|
|
fail(w, http.StatusBadRequest, 20001, "请输入有效的中国大陆手机号")
|
|
return
|
|
}
|
|
if !validUserPassword(req.Password) {
|
|
fail(w, http.StatusBadRequest, 20001, passwordRule)
|
|
return
|
|
}
|
|
if req.Nickname == "" || len([]rune(req.Nickname)) > 50 || req.Gender < 0 || req.Gender > 2 || len([]rune(req.City)) > 50 || len([]rune(req.Bio)) > 500 {
|
|
fail(w, http.StatusBadRequest, 20001, "昵称须为 1–50 字,城市最多 50 字,简介最多 500 字,性别须为有效选项")
|
|
return
|
|
}
|
|
passwordHash, err := hashPassword(req.Password)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "创建用户失败")
|
|
return
|
|
}
|
|
phoneCipher, err := a.encryptPhone(req.Phone)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "加密手机号失败")
|
|
return
|
|
}
|
|
tx, err := a.db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "创建用户失败")
|
|
return
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
publicID := fmt.Sprintf("XY%d%s", time.Now().UnixMilli(), randomToken()[:5])
|
|
result, err := tx.ExecContext(r.Context(), `INSERT INTO users (public_id,country_code,phone_hash,phone_cipher,password_hash) VALUES (?,'+86',?,?,?)`, publicID, phoneHash(req.Phone), phoneCipher, passwordHash)
|
|
if err != nil {
|
|
var mysqlErr *mysql.MySQLError
|
|
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 && strings.Contains(mysqlErr.Message, "uk_users_phone_hash") {
|
|
fail(w, http.StatusConflict, 20001, "该手机号已被使用,请勿重复创建")
|
|
} else {
|
|
fail(w, http.StatusInternalServerError, 50001, "创建用户失败,请稍后重试")
|
|
}
|
|
return
|
|
}
|
|
userID, err := result.LastInsertId()
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "创建用户失败")
|
|
return
|
|
}
|
|
if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_profiles (user_id,nickname,gender,city_name,bio,profile_score) VALUES (?,?,?,?,?,30)`, userID, req.Nickname, req.Gender, req.City, req.Bio); err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "创建用户资料失败")
|
|
return
|
|
}
|
|
if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_privacy_settings (user_id) VALUES (?)`, userID); err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "初始化隐私设置失败")
|
|
return
|
|
}
|
|
if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_notification_settings (user_id) VALUES (?)`, userID); err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "初始化通知设置失败")
|
|
return
|
|
}
|
|
// Audit and account creation succeed together. Do not store the request body:
|
|
// it contains the initial password and the full phone number.
|
|
auditData, _ := json.Marshal(map[string]any{"publicId": publicID, "nickname": req.Nickname, "source": "admin"})
|
|
if _, err = tx.ExecContext(r.Context(), `INSERT INTO admin_audit_logs(admin_user_id,action,target_type,target_id,request_data,ip) VALUES (?,'create_user','user',?,?,?)`, current(r).ID, userID, auditData, clientIP(r)); err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "记录创建日志失败,用户未创建")
|
|
return
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "创建用户失败")
|
|
return
|
|
}
|
|
reply(w, map[string]any{"id": userID, "publicId": publicID})
|
|
}
|