feat: 安全回写档口入库码并支持核验恢复 (#234)
This commit is contained in:
@@ -155,6 +155,70 @@ func (h *Handler) InnerCodeMatch(c *gin.Context) {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
|
||||
}
|
||||
|
||||
// InnerCodeApply 在操作员确认后逐条执行安全回写。
|
||||
func (h *Handler) InnerCodeApply(c *gin.Context) {
|
||||
businessDate, status, keyword := c.PostForm("date"), c.PostForm("status"), c.PostForm("q")
|
||||
pageNumber := service.ParsePage(c.PostForm("page"))
|
||||
ids := make([]int64, 0)
|
||||
for _, raw := range c.PostFormArray("ids") {
|
||||
if id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64); err == nil && id > 0 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
client, message := h.innerCodeSybClient()
|
||||
if message != "" {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
|
||||
return
|
||||
}
|
||||
result, err := service.ApplyInnerCodes(c.Request.Context(), h.db, client, ids, currentUser(c).UserID)
|
||||
if err != nil {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "回写未完成:"+err.Error())
|
||||
return
|
||||
}
|
||||
message = fmt.Sprintf("回写完成:选择 %d 条,成功 %d 条,已存在 %d 条,跳过 %d 条,失败 %d 条,需核对 %d 条。",
|
||||
result.Requested, result.Updated, result.AlreadyFilled, result.Skipped, result.Failed, result.NeedsCheck)
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
|
||||
}
|
||||
|
||||
// InnerCodeRecheck 只重新读取未知结果,绝不再次发送删除或写入。
|
||||
func (h *Handler) InnerCodeRecheck(c *gin.Context) {
|
||||
businessDate, status, keyword := c.PostForm("date"), c.PostForm("status"), c.PostForm("q")
|
||||
pageNumber := service.ParsePage(c.PostForm("page"))
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(c.PostForm("id")), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "记录编号无效,没有执行核对。")
|
||||
return
|
||||
}
|
||||
client, message := h.innerCodeSybClient()
|
||||
if message != "" {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
|
||||
return
|
||||
}
|
||||
_, message, err = service.RecheckInnerCode(c.Request.Context(), h.db, client, id)
|
||||
if err != nil {
|
||||
message = "重新核对失败:" + err.Error()
|
||||
}
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
|
||||
}
|
||||
|
||||
func (h *Handler) innerCodeSybClient() (*syb.Client, string) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, "顺运宝配置不可用,没有发送远端请求。"
|
||||
}
|
||||
client, err := syb.New(cfg.Syb.BaseURL)
|
||||
if err != nil {
|
||||
return nil, "顺运宝地址配置有误,没有发送远端请求。"
|
||||
}
|
||||
if err := service.EnsureSybSession(h.db, client, cfg.Syb.Username, time.Now()); err != nil {
|
||||
if errors.Is(err, service.ErrSybLoginRequired) {
|
||||
return nil, "顺运宝会话已过期,请先到“顺运宝数据”页面登录。"
|
||||
}
|
||||
return nil, "校验顺运宝会话失败,没有发送远端请求。"
|
||||
}
|
||||
return client, ""
|
||||
}
|
||||
|
||||
func (h *Handler) innerCodeRedirect(c *gin.Context, businessDate, status, keyword string, pageNumber int, message string) {
|
||||
values := url.Values{"date": {businessDate}, "page": {strconv.Itoa(pageNumber)}, "message": {message}}
|
||||
if strings.TrimSpace(status) != "" {
|
||||
|
||||
@@ -120,6 +120,8 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration, aiSecret
|
||||
innerCodes := pages.Group("/inner-codes")
|
||||
innerCodes.POST("/import", h.InnerCodeImport)
|
||||
innerCodes.POST("/match", h.InnerCodeMatch)
|
||||
innerCodes.POST("/apply", h.InnerCodeApply)
|
||||
innerCodes.POST("/recheck", h.InnerCodeRecheck)
|
||||
|
||||
// 7. 用户管理:先经过网页登录,再叠加管理员角色校验。
|
||||
users := pages.Group("/users", AdminRequired())
|
||||
|
||||
@@ -78,6 +78,13 @@ func main() {
|
||||
if aiInterrupted > 0 {
|
||||
log.Printf("已把 %d 个上次进程遗留的 AI 匹配批次标记为中断", aiInterrupted)
|
||||
}
|
||||
innerCodeInterrupted, err := service.InterruptApplyingInnerCodes(db, time.Now())
|
||||
if err != nil {
|
||||
log.Fatalf("恢复中断的档口入库码回写失败: %v", err)
|
||||
}
|
||||
if innerCodeInterrupted > 0 {
|
||||
log.Printf("已把 %d 条上次进程遗留的档口入库码回写标记为需核对", innerCodeInterrupted)
|
||||
}
|
||||
log.Printf("数据库已就绪")
|
||||
|
||||
// 3. Web 引擎
|
||||
|
||||
@@ -274,6 +274,102 @@ func scanInnerCodeRecord(scanner innerCodeRowScanner) (model.InnerCodeRecord, er
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// ClaimInnerCodeForApply 原子领取一条 ready 记录。返回 claimed=false 表示状态已变化。
|
||||
func ClaimInnerCodeForApply(db *sql.DB, id int64, actorUserID, now string) (*model.InnerCodeRecord, bool, error) {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("开始领取档口入库码事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
record, err := scanInnerCodeRecord(tx.QueryRow(`SELECT `+innerCodeListColumns+
|
||||
` FROM syb_inner_code_records WHERE id=? FOR UPDATE`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if record.Status != model.InnerCodeReady {
|
||||
return &record, false, nil
|
||||
}
|
||||
result, err := tx.Exec(`UPDATE syb_inner_code_records
|
||||
SET status='applying',applied_by_user_id=?,apply_started_at=?,updated_at=?
|
||||
WHERE id=? AND status='ready'`, actorUserID, now, now, id)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("领取档口入库码记录失败: %w", err)
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||
return &record, false, nil
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, false, fmt.Errorf("提交档口入库码领取失败: %w", err)
|
||||
}
|
||||
record.Status = model.InnerCodeApplying
|
||||
record.AppliedByUserID = actorUserID
|
||||
record.ApplyStartedAt = now
|
||||
record.UpdatedAt = now
|
||||
return &record, true, nil
|
||||
}
|
||||
|
||||
// FinishInnerCodeApply 保存一条已领取记录的最终结果。
|
||||
func FinishInnerCodeApply(q Execer, id int64, status model.InnerCodeStatus, message, remoteCode, finishedAt string) error {
|
||||
result, err := q.Exec(`UPDATE syb_inner_code_records
|
||||
SET status=?,result_message=?,remote_inner_code=?,
|
||||
applied_at=CASE WHEN ? IN ('updated','already_filled') THEN ? ELSE applied_at END,
|
||||
updated_at=?
|
||||
WHERE id=? AND status='applying'`, status, message, nullableString(remoteCode), status, finishedAt, finishedAt, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存档口入库码回写结果失败: %w", err)
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||
return fmt.Errorf("档口入库码记录 %d 已不在回写中,拒绝覆盖结果", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInnerCodeForRecheck 读取一条需核对记录。
|
||||
func GetInnerCodeForRecheck(q Execer, id int64) (*model.InnerCodeRecord, error) {
|
||||
record, err := scanInnerCodeRecord(q.QueryRow(`SELECT `+innerCodeListColumns+
|
||||
` FROM syb_inner_code_records WHERE id=?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// SaveInnerCodeRecheck 保存只读重新核对的远端结果,不执行状态领取或写入。
|
||||
func SaveInnerCodeRecheck(q Execer, id int64, status model.InnerCodeStatus, message, remoteCode, checkedAt string) error {
|
||||
result, err := q.Exec(`UPDATE syb_inner_code_records
|
||||
SET status=?,result_message=?,remote_inner_code=?,
|
||||
applied_at=CASE WHEN ?='updated' THEN ? ELSE applied_at END,updated_at=?
|
||||
WHERE id=? AND status='needs_check'`, status, message, nullableString(remoteCode), status, checkedAt, checkedAt, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存档口入库码核对结果失败: %w", err)
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||
return fmt.Errorf("档口入库码记录 %d 已不需要核对", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InterruptApplyingInnerCodes 在启动时把未知结果的 applying 收敛为 needs_check。
|
||||
func InterruptApplyingInnerCodes(q Execer, interruptedAt string) (int, error) {
|
||||
result, err := q.Exec(`UPDATE syb_inner_code_records
|
||||
SET status='needs_check',result_message='Admin 在回写完成前退出,请重新核对远端结果;系统不会自动重写',
|
||||
updated_at=? WHERE status='applying'`, interruptedAt)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("恢复中断的档口入库码回写失败: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("读取中断档口入库码数量失败: %w", err)
|
||||
}
|
||||
return int(affected), nil
|
||||
}
|
||||
|
||||
func nullablePositiveInt(value int) any {
|
||||
if value <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestInterruptApplyingInnerCodes_只收敛回写中记录(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", "file:inner_code_interrupt?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`CREATE TABLE syb_inner_code_records (
|
||||
id INTEGER PRIMARY KEY,status TEXT NOT NULL,result_message TEXT,updated_at TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO syb_inner_code_records(id,status,updated_at) VALUES
|
||||
(1,'applying','old'),(2,'ready','old')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count, err := InterruptApplyingInnerCodes(db, "2026-08-15T01:00:00Z")
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("count=%d err=%v", count, err)
|
||||
}
|
||||
var status, message, updatedAt string
|
||||
if err := db.QueryRow(`SELECT status,result_message,updated_at FROM syb_inner_code_records WHERE id=1`).
|
||||
Scan(&status, &message, &updatedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "needs_check" || message == "" || updatedAt != "2026-08-15T01:00:00Z" {
|
||||
t.Fatalf("status=%s message=%q updated=%s", status, message, updatedAt)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT status FROM syb_inner_code_records WHERE id=2`).Scan(&status); err != nil || status != "ready" {
|
||||
t.Fatalf("ready 记录不应变化 status=%s err=%v", status, err)
|
||||
}
|
||||
}
|
||||
@@ -1083,6 +1083,40 @@ func prepareMySQLV8(t *testing.T, db *sql.DB) {
|
||||
mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES(8,'2026-08-11T00:00:00Z')`)
|
||||
}
|
||||
|
||||
func TestMySQLMigrate_V22升级V23且唯一约束生效(t *testing.T) {
|
||||
db := openMySQLMigrationTestDB(t)
|
||||
defer db.Close()
|
||||
cleanMySQLTestSchema(t, db)
|
||||
defer cleanMySQLTestSchema(t, db)
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustExec(t, db, `SET FOREIGN_KEY_CHECKS=0`)
|
||||
mustExec(t, db, `DROP TABLE syb_inner_code_records`)
|
||||
mustExec(t, db, `DELETE FROM schema_migrations WHERE version=23`)
|
||||
mustExec(t, db, `SET FOREIGN_KEY_CHECKS=1`)
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
t.Fatalf("v22 升级 v23 失败: %v", err)
|
||||
}
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
t.Fatalf("v23 重复迁移失败: %v", err)
|
||||
}
|
||||
if err := checkMySQLV23Shape(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := "2026-08-15T00:00:00Z"
|
||||
mustExec(t, db, `INSERT INTO users(user_id,username,password_hash,role,status,password_changed_at,created_at,updated_at)
|
||||
VALUES('inner-user','inner-user','hash','purchaser','active',?,?,?)`, now, now, now)
|
||||
mustExec(t, db, `INSERT INTO syb_inner_code_records
|
||||
(business_date,source_row,order_number,stall,spec_raw,spec_key,inner_code,status,created_by_user_id,created_at,updated_at)
|
||||
VALUES('2026-08-15',2,'ORDER-1','A#1','黑色,M','黑色,M','DK-1','pending','inner-user',?,?)`, now, now)
|
||||
if _, err := db.Exec(`INSERT INTO syb_inner_code_records
|
||||
(business_date,source_row,order_number,stall,spec_raw,spec_key,inner_code,status,created_by_user_id,created_at,updated_at)
|
||||
VALUES('2026-08-15',3,'ORDER-2','B#2','白色,L','白色,L','DK-1','pending','inner-user',?,?)`, now, now); err == nil {
|
||||
t.Fatal("同一业务日期的 inner_code 唯一约束必须拒绝冲突")
|
||||
}
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(query, args...); err != nil {
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
"cmautobuy/admin/syb"
|
||||
)
|
||||
|
||||
// InnerCodeWriter 是安全回写所需的最小顺运宝接口。
|
||||
type InnerCodeWriter interface {
|
||||
InnerCodeDetailReader
|
||||
DeleteInnerCode(context.Context, int64) error
|
||||
UpdateDetailCode(context.Context, int64, int64, string) error
|
||||
}
|
||||
|
||||
// InnerCodeApplyResult 是一次用户确认操作的统计。
|
||||
type InnerCodeApplyResult struct {
|
||||
Requested int
|
||||
Updated int
|
||||
AlreadyFilled int
|
||||
Skipped int
|
||||
Failed int
|
||||
NeedsCheck int
|
||||
}
|
||||
|
||||
type innerCodeApplyOutcome struct {
|
||||
Status model.InnerCodeStatus
|
||||
Message string
|
||||
RemoteCode string
|
||||
}
|
||||
|
||||
// ApplyInnerCodes 逐条原子领取并回写。每条写请求只发送一次,单条失败不阻断后续行。
|
||||
func ApplyInnerCodes(ctx context.Context, db *sql.DB, writer InnerCodeWriter, ids []int64, actorUserID string) (*InnerCodeApplyResult, error) {
|
||||
ids = uniquePositiveInnerCodeIDs(ids)
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("没有选择可回写记录")
|
||||
}
|
||||
if len(ids) > PageSize {
|
||||
return nil, fmt.Errorf("单次最多回写当前页 %d 条记录", PageSize)
|
||||
}
|
||||
result := &InnerCodeApplyResult{Requested: len(ids)}
|
||||
for _, id := range ids {
|
||||
now := model.NowISO()
|
||||
record, claimed, err := repository.ClaimInnerCodeForApply(db, id, actorUserID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !claimed {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
outcome := applyClaimedInnerCode(ctx, writer, *record)
|
||||
finishedAt := model.NowISO()
|
||||
if err := repository.FinishInnerCodeApply(db, id, outcome.Status, compactInnerCodeMessage(outcome.Message), outcome.RemoteCode, finishedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch outcome.Status {
|
||||
case model.InnerCodeUpdated:
|
||||
result.Updated++
|
||||
case model.InnerCodeAlreadyFilled:
|
||||
result.AlreadyFilled++
|
||||
case model.InnerCodeNeedsCheck:
|
||||
result.NeedsCheck++
|
||||
case model.InnerCodeFailed:
|
||||
result.Failed++
|
||||
default:
|
||||
result.Skipped++
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func applyClaimedInnerCode(ctx context.Context, writer InnerCodeWriter, record model.InnerCodeRecord) innerCodeApplyOutcome {
|
||||
item, err := readCurrentInnerCodeDetail(ctx, writer, record)
|
||||
if err != nil {
|
||||
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
|
||||
Message: "写入前重新读取失败,没有发送删除或写入请求:" + err.Error(), RemoteCode: record.RemoteInnerCode}
|
||||
}
|
||||
currentCode := innerCodeRawText(item.Raw["innerExpCode"])
|
||||
if !innerCodeDetailIdentityMatches(record, *item) {
|
||||
return innerCodeApplyOutcome{Status: model.InnerCodeSkipped,
|
||||
Message: "顺运宝商品规格或档口身份已变化,停止回写,请重新匹配", RemoteCode: currentCode}
|
||||
}
|
||||
platform := innerCodeRawText(item.Raw["purchasePlatform"])
|
||||
purchaseCode := innerCodeRawText(item.Raw["purchaseCode"])
|
||||
if platform != "" || purchaseCode != "" {
|
||||
return innerCodeApplyOutcome{Status: model.InnerCodeSkipped,
|
||||
Message: "顺运宝商品已有采购平台或采购单号,停止回写", RemoteCode: currentCode}
|
||||
}
|
||||
if currentCode == record.InnerCode {
|
||||
return innerCodeApplyOutcome{Status: model.InnerCodeAlreadyFilled,
|
||||
Message: "写入前核验发现远端已是目标入库码,无需重复写入", RemoteCode: currentCode}
|
||||
}
|
||||
if currentCode != record.RemoteInnerCode {
|
||||
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
|
||||
Message: "远端快递单号在规划后发生变化,已停止回写,请人工核对", RemoteCode: currentCode}
|
||||
}
|
||||
if currentCode != "" {
|
||||
if err := writer.DeleteInnerCode(ctx, record.DetailID); err != nil {
|
||||
status := model.InnerCodeFailed
|
||||
message := "删除旧快递单号失败,未发送新值写入请求:" + err.Error()
|
||||
if errors.Is(err, syb.ErrWriteResultUnknown) {
|
||||
status = model.InnerCodeNeedsCheck
|
||||
message = "删除旧快递单号的结果未知,禁止自动继续写入,请重新核对"
|
||||
}
|
||||
return innerCodeApplyOutcome{Status: status, Message: message, RemoteCode: currentCode}
|
||||
}
|
||||
currentCode = ""
|
||||
}
|
||||
if err := writer.UpdateDetailCode(ctx, record.StockID, record.DetailID, record.InnerCode); err != nil {
|
||||
status := model.InnerCodeFailed
|
||||
message := "写入档口入库码失败,系统不会自动重试:" + err.Error()
|
||||
if errors.Is(err, syb.ErrWriteResultUnknown) {
|
||||
status = model.InnerCodeNeedsCheck
|
||||
message = "写入结果未知,禁止自动重试,请重新核对"
|
||||
}
|
||||
return innerCodeApplyOutcome{Status: status, Message: message, RemoteCode: currentCode}
|
||||
}
|
||||
verified, err := readCurrentInnerCodeDetail(ctx, writer, record)
|
||||
if err != nil {
|
||||
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
|
||||
Message: "写入请求已成功响应,但重新读取失败,请核对远端结果", RemoteCode: currentCode}
|
||||
}
|
||||
verifiedCode := innerCodeRawText(verified.Raw["innerExpCode"])
|
||||
if !innerCodeDetailIdentityMatches(record, *verified) {
|
||||
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
|
||||
Message: "写入后商品规格或档口身份发生变化,请人工核对", RemoteCode: verifiedCode}
|
||||
}
|
||||
if verifiedCode != record.InnerCode {
|
||||
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
|
||||
Message: "写入后远端值与目标入库码不一致,禁止自动重试,请人工核对", RemoteCode: verifiedCode}
|
||||
}
|
||||
return innerCodeApplyOutcome{Status: model.InnerCodeUpdated,
|
||||
Message: "回写完成,远端再次读取结果一致", RemoteCode: verifiedCode}
|
||||
}
|
||||
|
||||
func readCurrentInnerCodeDetail(ctx context.Context, reader InnerCodeDetailReader, record model.InnerCodeRecord) (*syb.DetailItem, error) {
|
||||
if record.StockID <= 0 || record.DetailID <= 0 {
|
||||
return nil, fmt.Errorf("记录缺少有效的货运单或商品明细 ID")
|
||||
}
|
||||
stocks, err := reader.DetailListByStock(ctx, []int64{record.StockID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(stocks) != 1 || stocks[0].ID != record.StockID {
|
||||
return nil, fmt.Errorf("顺运宝没有唯一返回货运单 id=%d", record.StockID)
|
||||
}
|
||||
var found *syb.DetailItem
|
||||
for index := range stocks[0].Details {
|
||||
if stocks[0].Details[index].ID != record.DetailID {
|
||||
continue
|
||||
}
|
||||
if found != nil {
|
||||
return nil, fmt.Errorf("顺运宝重复返回商品明细 id=%d", record.DetailID)
|
||||
}
|
||||
item := stocks[0].Details[index]
|
||||
found = &item
|
||||
}
|
||||
if found == nil {
|
||||
return nil, fmt.Errorf("顺运宝未返回商品明细 id=%d", record.DetailID)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// RecheckInnerCode 只重新读取一条 needs_check 记录,不发送任何写请求。
|
||||
func RecheckInnerCode(ctx context.Context, db *sql.DB, reader InnerCodeDetailReader, id int64) (model.InnerCodeStatus, string, error) {
|
||||
record, err := repository.GetInnerCodeForRecheck(db, id)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if record == nil {
|
||||
return "", "", fmt.Errorf("档口入库码记录不存在")
|
||||
}
|
||||
if record.Status != model.InnerCodeNeedsCheck {
|
||||
return record.Status, "当前记录不需要核对", nil
|
||||
}
|
||||
item, readErr := readCurrentInnerCodeDetail(ctx, reader, *record)
|
||||
status := model.InnerCodeNeedsCheck
|
||||
remoteCode := record.RemoteInnerCode
|
||||
message := "重新读取失败,仍需人工核对:" + errorText(readErr)
|
||||
if readErr == nil {
|
||||
remoteCode = innerCodeRawText(item.Raw["innerExpCode"])
|
||||
if !innerCodeDetailIdentityMatches(*record, *item) {
|
||||
message = "重新读取到的商品身份与规划不一致;保持需核对,系统没有写入"
|
||||
} else if remoteCode == record.InnerCode {
|
||||
status = model.InnerCodeUpdated
|
||||
message = "重新读取确认远端已是目标入库码;没有重复写入"
|
||||
} else {
|
||||
message = "重新读取后远端仍不是目标入库码;保持需核对,系统没有写入"
|
||||
}
|
||||
}
|
||||
checkedAt := model.NowISO()
|
||||
message = compactInnerCodeMessage(message)
|
||||
if err := repository.SaveInnerCodeRecheck(db, id, status, message, remoteCode, checkedAt); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return status, message, nil
|
||||
}
|
||||
|
||||
func innerCodeDetailIdentityMatches(record model.InnerCodeRecord, item syb.DetailItem) bool {
|
||||
return item.ProductSpec == record.SybSpec &&
|
||||
innerCodeRawText(item.Raw["sku"]) == record.SybSKU &&
|
||||
innerCodeRawText(item.Raw["variationSku"]) == record.SybVariationSKU
|
||||
}
|
||||
|
||||
func errorText(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func uniquePositiveInnerCodeIDs(ids []int64) []int64 {
|
||||
seen := make(map[int64]bool, len(ids))
|
||||
result := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id > 0 && !seen[id] {
|
||||
seen[id] = true
|
||||
result = append(result, id)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// InterruptApplyingInnerCodes 在 Admin 启动时收敛未确认的远端写结果。
|
||||
func InterruptApplyingInnerCodes(db *sql.DB, now time.Time) (int, error) {
|
||||
return repository.InterruptApplyingInnerCodes(db, now.UTC().Format(model.TimeLayout))
|
||||
}
|
||||
|
||||
func compactInnerCodeMessage(message string) string {
|
||||
message = strings.TrimSpace(message)
|
||||
if len([]rune(message)) <= 500 {
|
||||
return message
|
||||
}
|
||||
return string([]rune(message)[:500])
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/syb"
|
||||
)
|
||||
|
||||
type fakeInnerCodeWriter struct {
|
||||
stock syb.StockDetail
|
||||
readErr error
|
||||
deleteErr error
|
||||
updateErr error
|
||||
readCount int
|
||||
deleteCount int
|
||||
updateCount int
|
||||
}
|
||||
|
||||
func (f *fakeInnerCodeWriter) DetailListByStock(context.Context, []int64) ([]syb.StockDetail, error) {
|
||||
f.readCount++
|
||||
if f.readErr != nil {
|
||||
return nil, f.readErr
|
||||
}
|
||||
return []syb.StockDetail{f.stock}, nil
|
||||
}
|
||||
|
||||
func (f *fakeInnerCodeWriter) DeleteInnerCode(_ context.Context, detailID int64) error {
|
||||
f.deleteCount++
|
||||
if f.deleteErr != nil {
|
||||
return f.deleteErr
|
||||
}
|
||||
for index := range f.stock.Details {
|
||||
if f.stock.Details[index].ID == detailID {
|
||||
f.stock.Details[index].Raw["innerExpCode"] = ""
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeInnerCodeWriter) UpdateDetailCode(_ context.Context, _, detailID int64, code string) error {
|
||||
f.updateCount++
|
||||
if f.updateErr != nil {
|
||||
return f.updateErr
|
||||
}
|
||||
for index := range f.stock.Details {
|
||||
if f.stock.Details[index].ID == detailID {
|
||||
f.stock.Details[index].Raw["innerExpCode"] = code
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestApplyClaimedInnerCode_空旧值直接写并复读确认(t *testing.T) {
|
||||
record := innerCodeApplyTestRecord("")
|
||||
writer := innerCodeApplyTestWriter(record, "")
|
||||
outcome := applyClaimedInnerCode(context.Background(), writer, record)
|
||||
if outcome.Status != model.InnerCodeUpdated || writer.deleteCount != 0 || writer.updateCount != 1 || writer.readCount != 2 {
|
||||
t.Fatalf("outcome=%+v counts read=%d delete=%d update=%d", outcome, writer.readCount, writer.deleteCount, writer.updateCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyClaimedInnerCode_有旧值先删后写(t *testing.T) {
|
||||
record := innerCodeApplyTestRecord("OLD")
|
||||
writer := innerCodeApplyTestWriter(record, "OLD")
|
||||
outcome := applyClaimedInnerCode(context.Background(), writer, record)
|
||||
if outcome.Status != model.InnerCodeUpdated || writer.deleteCount != 1 || writer.updateCount != 1 {
|
||||
t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyClaimedInnerCode_规划后远端值变化不写入(t *testing.T) {
|
||||
record := innerCodeApplyTestRecord("OLD")
|
||||
writer := innerCodeApplyTestWriter(record, "OTHER")
|
||||
outcome := applyClaimedInnerCode(context.Background(), writer, record)
|
||||
if outcome.Status != model.InnerCodeNeedsCheck || writer.deleteCount != 0 || writer.updateCount != 0 {
|
||||
t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyClaimedInnerCode_删除明确失败不继续写(t *testing.T) {
|
||||
record := innerCodeApplyTestRecord("OLD")
|
||||
writer := innerCodeApplyTestWriter(record, "OLD")
|
||||
writer.deleteErr = errors.New("已打单数据不能清除")
|
||||
outcome := applyClaimedInnerCode(context.Background(), writer, record)
|
||||
if outcome.Status != model.InnerCodeFailed || writer.deleteCount != 1 || writer.updateCount != 0 {
|
||||
t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyClaimedInnerCode_写入未知结果不复读也不重试(t *testing.T) {
|
||||
record := innerCodeApplyTestRecord("")
|
||||
writer := innerCodeApplyTestWriter(record, "")
|
||||
writer.updateErr = fmtUnknownInnerCodeWriteError()
|
||||
outcome := applyClaimedInnerCode(context.Background(), writer, record)
|
||||
if outcome.Status != model.InnerCodeNeedsCheck || writer.updateCount != 1 || writer.readCount != 1 {
|
||||
t.Fatalf("outcome=%+v update=%d read=%d", outcome, writer.updateCount, writer.readCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyClaimedInnerCode_采购字段出现后停止(t *testing.T) {
|
||||
record := innerCodeApplyTestRecord("")
|
||||
writer := innerCodeApplyTestWriter(record, "")
|
||||
writer.stock.Details[0].Raw["purchaseCode"] = "CG-1"
|
||||
outcome := applyClaimedInnerCode(context.Background(), writer, record)
|
||||
if outcome.Status != model.InnerCodeSkipped || writer.updateCount != 0 {
|
||||
t.Fatalf("outcome=%+v update=%d", outcome, writer.updateCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyClaimedInnerCode_档口身份变化后停止(t *testing.T) {
|
||||
record := innerCodeApplyTestRecord("")
|
||||
record.SybSKU = "A#1"
|
||||
writer := innerCodeApplyTestWriter(record, "")
|
||||
writer.stock.Details[0].Raw["sku"] = "B#2"
|
||||
outcome := applyClaimedInnerCode(context.Background(), writer, record)
|
||||
if outcome.Status != model.InnerCodeSkipped || writer.updateCount != 0 {
|
||||
t.Fatalf("outcome=%+v update=%d", outcome, writer.updateCount)
|
||||
}
|
||||
}
|
||||
|
||||
func innerCodeApplyTestRecord(oldCode string) model.InnerCodeRecord {
|
||||
return model.InnerCodeRecord{ID: 1, StockID: 10, DetailID: 20, SybSpec: "黑色,M",
|
||||
InnerCode: "DK-001", RemoteInnerCode: oldCode, Status: model.InnerCodeApplying}
|
||||
}
|
||||
|
||||
func innerCodeApplyTestWriter(record model.InnerCodeRecord, currentCode string) *fakeInnerCodeWriter {
|
||||
item := syb.DetailItem{ID: record.DetailID, ProductSpec: record.SybSpec, Raw: map[string]any{
|
||||
"id": record.DetailID, "productSpec": record.SybSpec, "innerExpCode": currentCode,
|
||||
"purchasePlatform": "", "purchaseCode": "",
|
||||
}}
|
||||
return &fakeInnerCodeWriter{stock: syb.StockDetail{ID: record.StockID, Details: []syb.DetailItem{item}}}
|
||||
}
|
||||
|
||||
func fmtUnknownInnerCodeWriteError() error {
|
||||
return errors.Join(syb.ErrWriteResultUnknown, errors.New("timeout"))
|
||||
}
|
||||
+61
-4
@@ -24,6 +24,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ErrSessionInvalid 表示服务端**明确**判定当前会话未登录或已过期
|
||||
@@ -37,6 +38,15 @@ import (
|
||||
// 还可能把本来有效的会话丢掉,见 §3.5 的理由和工单 #46。
|
||||
var ErrSessionInvalid = errors.New("顺运宝会话未登录或已过期")
|
||||
|
||||
// ErrWriteResultUnknown 表示写请求可能已经到达顺运宝,但客户端无法确认结果。
|
||||
// 调用方只能重新读取核对,绝不能自动重发同一个写请求。
|
||||
var ErrWriteResultUnknown = errors.New("顺运宝写入结果未知")
|
||||
|
||||
type requestOutcomeUnknownError struct{ err error }
|
||||
|
||||
func (e requestOutcomeUnknownError) Error() string { return e.err.Error() }
|
||||
func (e requestOutcomeUnknownError) Unwrap() error { return e.err }
|
||||
|
||||
// Client 是一个顺运宝 ERP 会话:验证码、登录、货运单查询共用同一个
|
||||
// http.Client(同一个 Cookie Jar)。
|
||||
//
|
||||
@@ -177,20 +187,20 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values,
|
||||
if err != nil {
|
||||
// 网络故障(超时、连不上、DNS 失败……)——`[必须]` 不能当成"未登录",
|
||||
// 见 ErrSessionInvalid 的注释和 08 §3.5。
|
||||
return nil, fmt.Errorf("请求顺运宝接口 %s 失败(网络问题,不代表未登录): %w", path, err)
|
||||
return nil, requestOutcomeUnknownError{fmt.Errorf("请求顺运宝接口 %s 失败(网络问题,不代表未登录): %w", path, err)}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取顺运宝接口 %s 响应失败: %w", path, err)
|
||||
return nil, requestOutcomeUnknownError{fmt.Errorf("读取顺运宝接口 %s 响应失败: %w", path, err)}
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return nil, fmt.Errorf("顺运宝接口 %s 返回 %d: %w", path, resp.StatusCode, ErrSessionInvalid)
|
||||
}
|
||||
if resp.StatusCode >= http.StatusInternalServerError {
|
||||
return nil, fmt.Errorf("顺运宝接口 %s 返回 %d(服务端故障,不代表未登录)", path, resp.StatusCode)
|
||||
return nil, requestOutcomeUnknownError{fmt.Errorf("顺运宝接口 %s 返回 %d(服务端故障,不代表未登录)", path, resp.StatusCode)}
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("顺运宝接口 %s 返回意外状态码 %d", path, resp.StatusCode)
|
||||
@@ -198,7 +208,7 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values,
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return nil, fmt.Errorf("顺运宝接口 %s 响应不是合法 JSON(格式错误,不代表未登录): %w", path, err)
|
||||
return nil, requestOutcomeUnknownError{fmt.Errorf("顺运宝接口 %s 响应不是合法 JSON(格式错误,不代表未登录): %w", path, err)}
|
||||
}
|
||||
if !env.Status {
|
||||
if isSessionInvalidMessage(env.Msg, env.Code) {
|
||||
@@ -631,6 +641,53 @@ func (c *Client) DetailListByStock(ctx context.Context, ids []int64) ([]StockDet
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteInnerCode 清除一个货运明细的 innerExpCode。
|
||||
// 请求只发送一次;结果未知时返回 ErrWriteResultUnknown,调用方不得重试。
|
||||
func (c *Client) DeleteInnerCode(ctx context.Context, detailID int64) error {
|
||||
if detailID <= 0 {
|
||||
return fmt.Errorf("detailId 必须是正整数")
|
||||
}
|
||||
_, err := c.do(ctx, http.MethodGet, "/am/stock/detail/deleteInnerCode",
|
||||
url.Values{"detailId": {strconv.FormatInt(detailID, 10)}}, nil)
|
||||
return classifyInnerCodeWriteError(err)
|
||||
}
|
||||
|
||||
// UpdateDetailCode 把档口入库码写入货运明细的 innerExpCode。
|
||||
// 请求只发送一次;结果未知时返回 ErrWriteResultUnknown,调用方不得重试。
|
||||
func (c *Client) UpdateDetailCode(ctx context.Context, stockID, detailID int64, code string) error {
|
||||
code = strings.TrimSpace(code)
|
||||
if stockID <= 0 || detailID <= 0 {
|
||||
return fmt.Errorf("货运单 id 和 detailId 必须是正整数")
|
||||
}
|
||||
if code == "" {
|
||||
return fmt.Errorf("code 不能为空")
|
||||
}
|
||||
if utf8.RuneCountInString(code) > 128 {
|
||||
return fmt.Errorf("code 不能超过 128 个字符")
|
||||
}
|
||||
for _, character := range code {
|
||||
if character < 32 || character == 127 {
|
||||
return fmt.Errorf("code 不能包含控制字符")
|
||||
}
|
||||
}
|
||||
_, err := c.do(ctx, http.MethodGet, "/am/stock/detail/updateDetailCode", url.Values{
|
||||
"t": {"0"}, "id": {strconv.FormatInt(stockID, 10)},
|
||||
"detailId": {strconv.FormatInt(detailID, 10)}, "code": {code},
|
||||
}, nil)
|
||||
return classifyInnerCodeWriteError(err)
|
||||
}
|
||||
|
||||
func classifyInnerCodeWriteError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var unknown requestOutcomeUnknownError
|
||||
if errors.As(err, &unknown) {
|
||||
return fmt.Errorf("%w:%v", ErrWriteResultUnknown, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- 类型转换:JSON 数字/字符串统一转 ----------
|
||||
|
||||
// toInt64 兼容 JSON 数字被 encoding/json 解成 float64、以及顺运宝个别
|
||||
|
||||
@@ -431,6 +431,85 @@ func TestClient_DetailListByStock_超过100个id报错(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_InnerCodeWrite_参数和路径正确且只发送一次(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/am/stock/detail/deleteInnerCode":
|
||||
if r.URL.Query().Get("detailId") != "22" {
|
||||
t.Errorf("delete query=%v", r.URL.Query())
|
||||
}
|
||||
case "/am/stock/detail/updateDetailCode":
|
||||
query := r.URL.Query()
|
||||
if query.Get("t") != "0" || query.Get("id") != "11" || query.Get("detailId") != "22" || query.Get("code") != "DK-001" {
|
||||
t.Errorf("update query=%v", query)
|
||||
}
|
||||
default:
|
||||
t.Errorf("意外路径 %s", r.URL.Path)
|
||||
}
|
||||
io.WriteString(w, `{"status":true,"msg":"成功","data":null}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.DeleteInnerCode(context.Background(), 22); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.UpdateDetailCode(context.Background(), 11, 22, " DK-001 "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requests.Load() != 2 {
|
||||
t.Fatalf("每个写动作只能发一次请求,实际总请求 %d", requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_InnerCodeWrite_未知结果与明确业务失败分开(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
wantUnknown bool
|
||||
}{
|
||||
{"服务端故障", http.StatusInternalServerError, `oops`, true},
|
||||
{"成功响应损坏", http.StatusOK, `not-json`, true},
|
||||
{"明确业务失败", http.StatusOK, `{"status":false,"msg":"已打单数据不能清除","code":1}`, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tc.status)
|
||||
io.WriteString(w, tc.body)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := New(server.URL)
|
||||
err := client.DeleteInnerCode(context.Background(), 22)
|
||||
if err == nil || errors.Is(err, ErrWriteResultUnknown) != tc.wantUnknown {
|
||||
t.Fatalf("err=%v unknown=%v want=%v", err, errors.Is(err, ErrWriteResultUnknown), tc.wantUnknown)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_UpdateDetailCode_本地校验失败不发送请求(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := New(server.URL)
|
||||
for _, code := range []string{"", "bad\ncode", strings.Repeat("长", 129)} {
|
||||
if err := client.UpdateDetailCode(context.Background(), 1, 2, code); err == nil {
|
||||
t.Errorf("code=%q 应被拒绝", code)
|
||||
}
|
||||
}
|
||||
if requests.Load() != 0 {
|
||||
t.Fatalf("本地校验失败不应发请求,实际 %d", requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// ── 业务失败但不是登录问题 ──────────────────────────────
|
||||
|
||||
func TestClient_业务失败但不是登录问题时返回普通错误(t *testing.T) {
|
||||
|
||||
@@ -410,6 +410,23 @@ SKU(平均每商品 1.17 个),**查无此 SKU 是常态**,不是异常
|
||||
`[建议]` 登录后**只调 `/am/user/get` 验证会话**,其余几个是网页自己的初始化请求,
|
||||
Go 侧不用跟着调。
|
||||
|
||||
### 7.1 档口入库码写入(工单 #234)
|
||||
|
||||
档口入库码最终写入货运明细的 `innerExpCode`(页面名称“快递单号”)。两个写接口来自
|
||||
现有 Python 流程和线上响应样本:
|
||||
|
||||
```text
|
||||
GET /am/stock/detail/deleteInnerCode?detailId={detailID}
|
||||
GET /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code={innerCode}
|
||||
```
|
||||
|
||||
- 旧值为空时不调用删除;旧值非空时,只有删除明确成功才发送写入。
|
||||
- 删除和写入请求都只允许发送一次,不使用自动重试。超时、5xx、响应读取失败或成功响应
|
||||
无法解析时,结果可能已经在远端生效,必须标为“需核对”。
|
||||
- 每条写入前重新调用 `listByStock`,核对 stock/detail、规格、采购平台、采购单号和规划时
|
||||
的旧值;写入后再次读取,只有 `innerExpCode` 与目标一致才标为已回写。
|
||||
- “重新核对”只调用 `listByStock`,绝不能再次调用上述两个写接口。
|
||||
|
||||
---
|
||||
|
||||
## 8. 实现时的固定约束
|
||||
|
||||
Reference in New Issue
Block a user