feat: sync durable moderation and appeals
This commit is contained in:
parent
e1a95c7318
commit
9f467f4be7
140 changed files with 13730 additions and 316 deletions
|
|
@ -57,6 +57,13 @@ type Service interface {
|
|||
EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error)
|
||||
StarGiftCollectibles(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
|
||||
StarGiftCollectibleAnimation(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
|
||||
ModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error)
|
||||
ModerationCase(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error)
|
||||
ModerationReport(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error)
|
||||
ClaimModerationCase(ctx context.Context, caseID, expectedVersion int64, actor string) (domain.ModerationCase, error)
|
||||
DecideModerationCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
|
||||
SubmitModerationAppeal(ctx context.Context, caseID, appellantUserID int64, text string) (domain.ModerationAppeal, bool, error)
|
||||
ReviewModerationAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
|
||||
}
|
||||
|
||||
func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http.Server, error) {
|
||||
|
|
@ -137,6 +144,13 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("GET /v1/emoji/{id}/animation", s.authenticated(s.handleEmojiAnimation))
|
||||
mux.HandleFunc("GET /v1/gifts/{id}/collectibles", s.authenticated(s.handleStarGiftCollectibles))
|
||||
mux.HandleFunc("GET /v1/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.authenticated(s.handleStarGiftCollectibleAnimation))
|
||||
mux.HandleFunc("GET /v1/moderation/cases", s.authenticated(s.handleModerationCases))
|
||||
mux.HandleFunc("GET /v1/moderation/cases/{id}", s.authenticated(s.handleModerationCase))
|
||||
mux.HandleFunc("GET /v1/moderation/reports/{id}", s.authenticated(s.handleModerationReport))
|
||||
mux.HandleFunc("POST /v1/moderation/cases/{id}/claim", s.authenticated(s.handleClaimModerationCase))
|
||||
mux.HandleFunc("POST /v1/moderation/cases/{id}/decide", s.authenticated(s.handleDecideModerationCase))
|
||||
mux.HandleFunc("POST /v1/moderation/cases/{id}/appeals", s.authenticated(s.handleSubmitModerationAppeal))
|
||||
mux.HandleFunc("POST /v1/moderation/cases/{id}/appeals/{appeal_id}/review", s.authenticated(s.handleReviewModerationAppeal))
|
||||
return mux
|
||||
}
|
||||
|
||||
|
|
@ -634,6 +648,267 @@ func (s *Server) handleStarGiftCollectibleAnimation(w http.ResponseWriter, r *ht
|
|||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
type moderationClaimRequest struct {
|
||||
ExpectedVersion int64 `json:"expected_version"`
|
||||
Actor string `json:"actor"`
|
||||
}
|
||||
|
||||
type moderationActionRequest struct {
|
||||
Kind domain.ModerationActionKind `json:"kind"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
type moderationDecisionRequest struct {
|
||||
ExpectedVersion int64 `json:"expected_version"`
|
||||
Actor string `json:"actor"`
|
||||
Reason string `json:"reason"`
|
||||
CommandID string `json:"command_id"`
|
||||
Kind domain.ModerationDecisionKind `json:"kind"`
|
||||
Actions []moderationActionRequest `json:"actions"`
|
||||
}
|
||||
|
||||
type moderationAppealRequest struct {
|
||||
AppellantUserID int64 `json:"appellant_user_id"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type moderationAppealReviewRequest struct {
|
||||
ExpectedVersion int64 `json:"expected_version"`
|
||||
Actor string `json:"actor"`
|
||||
Reason string `json:"reason"`
|
||||
CommandID string `json:"command_id"`
|
||||
Granted bool `json:"granted"`
|
||||
Actions []moderationActionRequest `json:"actions"`
|
||||
}
|
||||
|
||||
func (s *Server) handleModerationCases(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
limit := 50
|
||||
if raw := query.Get("limit"); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
filter := domain.ModerationCaseFilter{
|
||||
AssignedTo: query.Get("assigned_to"),
|
||||
Limit: limit,
|
||||
}
|
||||
if raw := query.Get("statuses"); raw != "" {
|
||||
for _, status := range strings.Split(raw, ",") {
|
||||
if status = strings.TrimSpace(status); status != "" {
|
||||
filter.Statuses = append(filter.Statuses, domain.ModerationCaseStatus(status))
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw := query.Get("target_id"); raw != "" {
|
||||
id, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid target id")
|
||||
return
|
||||
}
|
||||
filter.Target = domain.Peer{
|
||||
Type: domain.PeerType(query.Get("target_type")), ID: id,
|
||||
}
|
||||
}
|
||||
if raw := query.Get("before_updated_at"); raw != "" {
|
||||
parsed, err := time.Parse(time.RFC3339Nano, raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid before_updated_at")
|
||||
return
|
||||
}
|
||||
filter.BeforeUpdate = parsed
|
||||
filter.BeforeID, _ = strconv.ParseInt(query.Get("before_id"), 10, 64)
|
||||
}
|
||||
items, err := s.svc.ModerationCases(r.Context(), filter)
|
||||
if err != nil {
|
||||
writeModerationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"cases": items})
|
||||
}
|
||||
|
||||
func (s *Server) handleModerationCase(w http.ResponseWriter, r *http.Request) {
|
||||
caseID, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
detail, found, err := s.svc.ModerationCase(r.Context(), caseID)
|
||||
if err != nil {
|
||||
writeModerationError(w, err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "moderation case not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
func (s *Server) handleModerationReport(w http.ResponseWriter, r *http.Request) {
|
||||
reportID, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
report, found, err := s.svc.ModerationReport(r.Context(), reportID)
|
||||
if err != nil {
|
||||
writeModerationError(w, err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "moderation report not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, report)
|
||||
}
|
||||
|
||||
func (s *Server) handleClaimModerationCase(w http.ResponseWriter, r *http.Request) {
|
||||
caseID, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request moderationClaimRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
item, err := s.svc.ClaimModerationCase(
|
||||
r.Context(), caseID, request.ExpectedVersion, request.Actor,
|
||||
)
|
||||
if err != nil {
|
||||
writeModerationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleDecideModerationCase(w http.ResponseWriter, r *http.Request) {
|
||||
caseID, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request moderationDecisionRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
detail, created, err := s.svc.DecideModerationCase(
|
||||
r.Context(), moderationDecisionDomain(caseID, 0, request),
|
||||
)
|
||||
if err != nil {
|
||||
writeModerationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"created": created, "case": detail,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSubmitModerationAppeal(w http.ResponseWriter, r *http.Request) {
|
||||
caseID, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request moderationAppealRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
appeal, created, err := s.svc.SubmitModerationAppeal(
|
||||
r.Context(), caseID, request.AppellantUserID, request.Text,
|
||||
)
|
||||
if err != nil {
|
||||
writeModerationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"created": created, "appeal": appeal,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleReviewModerationAppeal(w http.ResponseWriter, r *http.Request) {
|
||||
caseID, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
appealID, ok := moderationPathID(w, r, "appeal_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request moderationAppealReviewRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
kind := domain.ModerationDecisionAppealDeny
|
||||
if request.Granted {
|
||||
kind = domain.ModerationDecisionAppealGrant
|
||||
}
|
||||
decision := moderationDecisionRequest{
|
||||
ExpectedVersion: request.ExpectedVersion, Actor: request.Actor,
|
||||
Reason: request.Reason, CommandID: request.CommandID,
|
||||
Kind: kind, Actions: request.Actions,
|
||||
}
|
||||
detail, created, err := s.svc.ReviewModerationAppeal(
|
||||
r.Context(), moderationDecisionDomain(caseID, appealID, decision),
|
||||
)
|
||||
if err != nil {
|
||||
writeModerationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"created": created, "case": detail,
|
||||
})
|
||||
}
|
||||
|
||||
func moderationDecisionDomain(caseID, appealID int64, request moderationDecisionRequest) domain.ModerationDecisionRequest {
|
||||
actions := make([]domain.ModerationActionDraft, 0, len(request.Actions))
|
||||
for _, action := range request.Actions {
|
||||
payload := action.Payload
|
||||
if len(payload) == 0 {
|
||||
payload = json.RawMessage(`{}`)
|
||||
}
|
||||
actions = append(actions, domain.ModerationActionDraft{
|
||||
Kind: action.Kind, Payload: payload,
|
||||
})
|
||||
}
|
||||
return domain.ModerationDecisionRequest{
|
||||
CaseID: caseID, AppealID: appealID,
|
||||
ExpectedVersion: request.ExpectedVersion, Actor: request.Actor,
|
||||
Reason: request.Reason, CommandID: request.CommandID,
|
||||
Kind: request.Kind, Actions: actions,
|
||||
}
|
||||
}
|
||||
|
||||
func moderationPathID(w http.ResponseWriter, r *http.Request, name string) (int64, bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue(name), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid "+name)
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func writeModerationError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrModerationCaseNotFound),
|
||||
errors.Is(err, domain.ErrModerationReportNotFound),
|
||||
errors.Is(err, domain.ErrModerationEvidenceNotFound):
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, domain.ErrModerationPermissionDenied):
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
case errors.Is(err, domain.ErrModerationCaseConflict),
|
||||
errors.Is(err, domain.ErrModerationActionConflict):
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, domain.ErrModerationRateLimited):
|
||||
writeError(w, http.StatusTooManyRequests, err.Error())
|
||||
case errors.Is(err, domain.ErrModerationCaseInvalid),
|
||||
errors.Is(err, domain.ErrModerationActionInvalid),
|
||||
errors.Is(err, domain.ErrModerationReportInvalid):
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
|
|
|
|||
|
|
@ -42,6 +42,85 @@ func TestAdminAPISetAccountFrozen(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
type captureModerationService struct {
|
||||
fakeService
|
||||
filter domain.ModerationCaseFilter
|
||||
decision domain.ModerationDecisionRequest
|
||||
appealReview domain.ModerationDecisionRequest
|
||||
}
|
||||
|
||||
func (s *captureModerationService) ModerationCases(_ context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
|
||||
s.filter = filter
|
||||
return []domain.ModerationCase{{ID: 7}}, nil
|
||||
}
|
||||
|
||||
func (s *captureModerationService) DecideModerationCase(_ context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||
s.decision = request
|
||||
return domain.ModerationCaseDetail{Case: domain.ModerationCase{ID: request.CaseID}}, true, nil
|
||||
}
|
||||
|
||||
func (s *captureModerationService) ReviewModerationAppeal(_ context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||
s.appealReview = request
|
||||
return domain.ModerationCaseDetail{Case: domain.ModerationCase{ID: request.CaseID}}, true, nil
|
||||
}
|
||||
|
||||
func TestAdminAPIModerationQueueDecisionAndAppealReview(t *testing.T) {
|
||||
svc := &captureModerationService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
listRequest := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/v1/moderation/cases?statuses=open,action_failed&assigned_to=alice&target_type=user&target_id=99&limit=25",
|
||||
nil,
|
||||
)
|
||||
listRequest.Header.Set("Authorization", "Bearer secret")
|
||||
list := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(list, listRequest)
|
||||
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"ID":7`) {
|
||||
t.Fatalf("list status=%d body=%s", list.Code, list.Body.String())
|
||||
}
|
||||
if len(svc.filter.Statuses) != 2 ||
|
||||
svc.filter.Statuses[0] != domain.ModerationCaseOpen ||
|
||||
svc.filter.Statuses[1] != domain.ModerationCaseActionFailed ||
|
||||
svc.filter.AssignedTo != "alice" ||
|
||||
svc.filter.Target != (domain.Peer{Type: domain.PeerTypeUser, ID: 99}) ||
|
||||
svc.filter.Limit != 25 {
|
||||
t.Fatalf("filter=%+v", svc.filter)
|
||||
}
|
||||
|
||||
decisionRequest := httptest.NewRequest(
|
||||
http.MethodPost, "/v1/moderation/cases/7/decide",
|
||||
strings.NewReader(`{"expected_version":3,"actor":"alice","reason":"confirmed","command_id":"decision-7","kind":"violation","actions":[{"kind":"mark_scam","payload":{}}]}`),
|
||||
)
|
||||
decisionRequest.Header.Set("Authorization", "Bearer secret")
|
||||
decision := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(decision, decisionRequest)
|
||||
if decision.Code != http.StatusOK ||
|
||||
!strings.Contains(decision.Body.String(), `"created":true`) ||
|
||||
svc.decision.CaseID != 7 || svc.decision.ExpectedVersion != 3 ||
|
||||
svc.decision.Kind != domain.ModerationDecisionViolation ||
|
||||
len(svc.decision.Actions) != 1 ||
|
||||
svc.decision.Actions[0].Kind != domain.ModerationActionMarkScam {
|
||||
t.Fatalf("decision status=%d request=%+v body=%s",
|
||||
decision.Code, svc.decision, decision.Body.String())
|
||||
}
|
||||
|
||||
reviewRequest := httptest.NewRequest(
|
||||
http.MethodPost, "/v1/moderation/cases/7/appeals/8/review",
|
||||
strings.NewReader(`{"expected_version":5,"actor":"bob","reason":"appeal accepted","command_id":"appeal-8","granted":true,"actions":[{"kind":"clear_peer_flags","payload":{}}]}`),
|
||||
)
|
||||
reviewRequest.Header.Set("Authorization", "Bearer secret")
|
||||
review := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(review, reviewRequest)
|
||||
if review.Code != http.StatusOK ||
|
||||
svc.appealReview.CaseID != 7 || svc.appealReview.AppealID != 8 ||
|
||||
svc.appealReview.Kind != domain.ModerationDecisionAppealGrant ||
|
||||
len(svc.appealReview.Actions) != 1 ||
|
||||
svc.appealReview.Actions[0].Kind != domain.ModerationActionClearPeerFlags {
|
||||
t.Fatalf("review status=%d request=%+v body=%s",
|
||||
review.Code, svc.appealReview, review.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPISetVerified(t *testing.T) {
|
||||
srv := &Server{token: "secret", svc: fakeService{}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/set-verified", strings.NewReader(`{"command_id":"c2","actor":"ops","reason":"official","dry_run":true,"user_id":1001,"verified":true}`))
|
||||
|
|
@ -357,3 +436,31 @@ func (fakeService) StarGiftCollectibles(context.Context, int64) (domain.StarGift
|
|||
func (fakeService) StarGiftCollectibleAnimation(context.Context, int64, domain.StarGiftCollectibleAttributeKind, int64) ([]byte, bool, error) {
|
||||
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
|
||||
}
|
||||
|
||||
func (fakeService) ModerationCases(context.Context, domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) ModerationCase(context.Context, int64) (domain.ModerationCaseDetail, bool, error) {
|
||||
return domain.ModerationCaseDetail{}, false, nil
|
||||
}
|
||||
|
||||
func (fakeService) ModerationReport(context.Context, int64) (domain.ModerationReport, bool, error) {
|
||||
return domain.ModerationReport{}, false, nil
|
||||
}
|
||||
|
||||
func (fakeService) ClaimModerationCase(context.Context, int64, int64, string) (domain.ModerationCase, error) {
|
||||
return domain.ModerationCase{}, nil
|
||||
}
|
||||
|
||||
func (fakeService) DecideModerationCase(context.Context, domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||
return domain.ModerationCaseDetail{}, true, nil
|
||||
}
|
||||
|
||||
func (fakeService) SubmitModerationAppeal(context.Context, int64, int64, string) (domain.ModerationAppeal, bool, error) {
|
||||
return domain.ModerationAppeal{}, true, nil
|
||||
}
|
||||
|
||||
func (fakeService) ReviewModerationAppeal(context.Context, domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||
return domain.ModerationCaseDetail{}, true, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue