added avatar and login mail to user list in admin panel
This commit is contained in:
parent
f358d5a64a
commit
24276d1379
14 changed files with 301 additions and 10 deletions
|
|
@ -46,6 +46,7 @@ type AccountRow struct {
|
|||
PremiumUntil int64
|
||||
LastActiveAt time.Time
|
||||
DeviceCount int
|
||||
LoginEmail string
|
||||
}
|
||||
|
||||
type AccountDetail struct {
|
||||
|
|
@ -209,11 +210,13 @@ SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.upd
|
|||
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified,
|
||||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
|
||||
COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), COALESCE(a.device_count, 0)::int,
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username,
|
||||
COALESCE(ap.login_email, '')
|
||||
FROM users u
|
||||
LEFT JOIN account_restrictions r ON r.user_id = u.id
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
|
||||
LEFT JOIN auth a ON a.user_id = u.id
|
||||
LEFT JOIN account_passwords ap ON ap.user_id = u.id
|
||||
WHERE u.id = $1 OR u.phone = $2 OR u.phone = $3 OR lower(u.username) = $4 OR p.username_lower = $4
|
||||
ORDER BY u.id
|
||||
LIMIT $5`, id, phone, phoneRaw, username, accountSearchLimit)
|
||||
|
|
@ -224,7 +227,7 @@ LIMIT $5`, id, phone, phoneRaw, username, accountSearchLimit)
|
|||
out := make([]AccountRow, 0)
|
||||
for rows.Next() {
|
||||
var item AccountRow
|
||||
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username, &item.LoginEmail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
|
|
@ -382,11 +385,13 @@ SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.upd
|
|||
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified,
|
||||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
|
||||
auth.last_active_at, auth.device_count,
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
|
||||
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username,
|
||||
COALESCE(ap.login_email, '')
|
||||
FROM users u
|
||||
JOIN auth ON auth.user_id = u.id
|
||||
LEFT JOIN account_restrictions r ON r.user_id = u.id
|
||||
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
|
||||
LEFT JOIN account_passwords ap ON ap.user_id = u.id
|
||||
WHERE NOT u.is_bot
|
||||
AND ($1::bigint = 0 OR (auth.last_active_at, u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
|
||||
ORDER BY auth.last_active_at DESC, u.id DESC
|
||||
|
|
@ -398,7 +403,7 @@ LIMIT $3`, beforeActiveUS, beforeID, limit+1)
|
|||
out := make([]AccountRow, 0, limit+1)
|
||||
for rows.Next() {
|
||||
var item AccountRow
|
||||
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username, &item.LoginEmail); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out = append(out, item)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession)))
|
||||
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
|
||||
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
|
||||
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
|
||||
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
|
||||
mux.Handle("GET /api/channels/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleChannelDetailAPI)))
|
||||
mux.Handle("GET /api/messages", s.requireAuthAPI(http.HandlerFunc(s.handleMessagesAPI)))
|
||||
|
|
@ -347,6 +348,47 @@ func (s *server) handleAccountDetailAPI(w http.ResponseWriter, r *http.Request)
|
|||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
// handleAccountAvatarAPI streams the account's current profile photo straight
|
||||
// through from the real telesrv admin API (/v1/accounts/{id}/avatar) — unlike
|
||||
// every other /api/* handler here, the response is raw image bytes, not JSON,
|
||||
// so it can't reuse callAdminAPI/writeJSON.
|
||||
func (s *server) handleAccountAvatarAPI(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := parseInt64(r.PathValue("id"))
|
||||
if err != nil || userID <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
apiPath := fmt.Sprintf("/v1/accounts/%d/avatar", userID)
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil || len(data) == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (s *server) handleChannelsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -5,8 +5,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<title>OwpenGram Admin</title>
|
||||
<script type="module" crossorigin src="/assets/index-CRrNXtUN.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C7HtHaDP.css">
|
||||
<script type="module" crossorigin src="/assets/index-CGbIqVNE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BAJpg0mp.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
78
cmd/telesrv-admin/web/src/components/Avatar.tsx
Normal file
78
cmd/telesrv-admin/web/src/components/Avatar.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
// Mirrors internal/web/server.go's publicAvatarGradients + initials() exactly,
|
||||
// so admin-console avatars look identical to the public preview cards.
|
||||
const AVATAR_GRADIENTS: [string, string][] = [
|
||||
["#FF885E", "#FF516A"],
|
||||
["#FFCD6A", "#FFA85C"],
|
||||
["#82B1FF", "#665FFF"],
|
||||
["#A0DE7E", "#54CB68"],
|
||||
["#53EDD6", "#28C9B7"],
|
||||
["#72D5FD", "#2A9EF1"],
|
||||
["#E0A2F3", "#D669ED"]
|
||||
];
|
||||
|
||||
function avatarGradient(id: number): [string, string] {
|
||||
const n = Math.abs(id) % AVATAR_GRADIENTS.length;
|
||||
return AVATAR_GRADIENTS[n];
|
||||
}
|
||||
|
||||
function firstCodePoint(word: string): string {
|
||||
const chars = Array.from(word);
|
||||
return chars.length > 0 ? chars[0] : "";
|
||||
}
|
||||
|
||||
function avatarInitials(firstName: string, lastName: string, username: string): string {
|
||||
const title = `${firstName} ${lastName}`.trim();
|
||||
const words = title.split(/\s+/).filter(Boolean);
|
||||
const source = words.length > 0 ? words : (username ? [username] : []);
|
||||
if (source.length === 0) return "T";
|
||||
let out = firstCodePoint(source[0]);
|
||||
if (source.length > 1) {
|
||||
out += firstCodePoint(source[source.length - 1]);
|
||||
}
|
||||
return out.toUpperCase();
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
userID,
|
||||
firstName,
|
||||
lastName,
|
||||
username = "",
|
||||
size = 34
|
||||
}: {
|
||||
userID: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
username?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
}, [userID]);
|
||||
|
||||
if (failed) {
|
||||
const [from, to] = avatarGradient(userID);
|
||||
return (
|
||||
<div
|
||||
className="avatar-fallback"
|
||||
style={{ width: size, height: size, background: `linear-gradient(135deg, ${from}, ${to})`, fontSize: Math.round(size * 0.42) }}
|
||||
>
|
||||
{avatarInitials(firstName, lastName, username)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className="avatar-photo-img"
|
||||
src={`/api/accounts/${userID}/avatar`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ width: size, height: size }}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -106,6 +106,7 @@ const translations: Record<string, string> = {
|
|||
"account.searchPlaceholder": "User ID / phone / username",
|
||||
"account.userID": "User ID",
|
||||
"account.phone": "Phone",
|
||||
"account.loginEmail": "Login email",
|
||||
"account.lastActive": "Last active",
|
||||
"account.notVerified": "Not verified",
|
||||
"account.notPremium": "Not premium",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
|
||||
|
|
@ -88,10 +89,12 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
|||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="avatar-col"></th>
|
||||
<th>{t("account.userID")}</th>
|
||||
<th>{t("account.phone")}</th>
|
||||
<th>{t("common.username")}</th>
|
||||
<th>{t("common.name")}</th>
|
||||
<th>{t("account.loginEmail")}</th>
|
||||
<th>{t("common.device")}</th>
|
||||
<th>{t("account.lastActive")}</th>
|
||||
<th>{t("account.premium")}</th>
|
||||
|
|
@ -104,10 +107,12 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
|||
<tbody>
|
||||
{data?.rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="avatar-col"><Avatar userID={row.ID} firstName={row.FirstName} lastName={row.LastName} username={row.Username} /></td>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td>{displayPhone(row.Phone)}</td>
|
||||
<td>{displayUsername(row.Username)}</td>
|
||||
<td>{displayName(row)}</td>
|
||||
<td>{row.LoginEmail || <span className="muted-cell">{t("common.none")}</span>}</td>
|
||||
<td>{row.DeviceCount}</td>
|
||||
<td>{formatDate(row.LastActiveAt)}</td>
|
||||
<td>{row.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")} {formatUnix(row.PremiumUntil)}</Badge> : <Badge>{t("common.none")}</Badge>}</td>
|
||||
|
|
@ -117,7 +122,7 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
|||
<td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{(!data || data.rows.length === 0) && <EmptyRow colSpan={11} />}
|
||||
{(!data || data.rows.length === 0) && <EmptyRow colSpan={12} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -413,6 +413,22 @@
|
|||
.gift-select-col { width: 34px; text-align: center; }
|
||||
.gift-select-col input { width: 15px; height: 15px; }
|
||||
|
||||
.avatar-col { width: 44px; }
|
||||
.muted-cell { color: var(--muted); }
|
||||
.avatar-photo-img,
|
||||
.avatar-fallback {
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.avatar-fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #ffffff;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.gift-bulk-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type AccountRow = {
|
|||
PremiumUntil: number;
|
||||
LastActiveAt: string;
|
||||
DeviceCount: number;
|
||||
LoginEmail: string;
|
||||
};
|
||||
|
||||
export type RestrictionRow = {
|
||||
|
|
|
|||
|
|
@ -884,6 +884,7 @@ func run(logger *zap.Logger) error {
|
|||
ChannelNotifier: router,
|
||||
Messages: messagesService,
|
||||
Gifts: giftsService,
|
||||
Photos: filesService,
|
||||
})
|
||||
// bot session 撤销、在线通知与 @ChatBot 流式草稿推送经 router 实现(需 tg.* 边界),
|
||||
// router 创建后注入。
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"sort"
|
||||
|
|
@ -115,6 +116,14 @@ type OfficialGiftsSource interface {
|
|||
Bundle(ctx context.Context, giftID int64, includeCollectible bool) (officialgifts.Bundle, error)
|
||||
}
|
||||
|
||||
// AvatarResolver is the same shape as internal/web's ProfilePhotoResolver, kept as its
|
||||
// own local interface (rather than importing internal/web) since only this narrow slice
|
||||
// is needed to serve an account's current profile photo in the admin console.
|
||||
type AvatarResolver interface {
|
||||
CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error)
|
||||
GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error)
|
||||
}
|
||||
|
||||
type Dependencies struct {
|
||||
Commands CommandRepository
|
||||
Restrictions RestrictionStore
|
||||
|
|
@ -129,6 +138,7 @@ type Dependencies struct {
|
|||
Messages MessagesService
|
||||
Gifts GiftsService
|
||||
OfficialGifts OfficialGiftsSource
|
||||
Photos AvatarResolver
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
|
|
@ -146,6 +156,7 @@ type Service struct {
|
|||
messages MessagesService
|
||||
gifts GiftsService
|
||||
officialGifts OfficialGiftsSource
|
||||
photos AvatarResolver
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
|
|
@ -194,6 +205,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
|
|||
if deps.OfficialGifts != nil {
|
||||
s.officialGifts = deps.OfficialGifts
|
||||
}
|
||||
if deps.Photos != nil {
|
||||
s.photos = deps.Photos
|
||||
}
|
||||
if deps.Now != nil {
|
||||
s.now = deps.Now
|
||||
}
|
||||
|
|
@ -862,6 +876,106 @@ func (s *Service) OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSu
|
|||
return s.officialGifts.List(ctx)
|
||||
}
|
||||
|
||||
const maxAccountAvatarBytes = 4 << 20
|
||||
|
||||
// AccountAvatar returns an account's current profile photo bytes and detected
|
||||
// MIME type, mirroring internal/web's public avatar serving (same size
|
||||
// selection and safe-image-type checks) so the admin console shows exactly
|
||||
// what a public preview card would show for the same account.
|
||||
func (s *Service) AccountAvatar(ctx context.Context, userID int64) ([]byte, string, bool, error) {
|
||||
if s == nil || s.photos == nil || userID <= 0 {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
photo, found, err := s.photos.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, userID, domain.ProfilePhotoKindProfile)
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
if !found {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
size, inline, ok := bestAccountPhotoSize(photo.Sizes)
|
||||
if !ok {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
data := inline
|
||||
if len(data) == 0 {
|
||||
chunk, found, err := s.photos.GetFile(ctx, domain.FileDownloadRequest{
|
||||
LocationKey: fmt.Sprintf("photo:%d:%s", photo.ID, size.Type),
|
||||
Limit: maxAccountAvatarBytes + 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
if !found || chunk.Total <= 0 || chunk.Total > maxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
data = chunk.Bytes
|
||||
}
|
||||
if len(data) == 0 || len(data) > maxAccountAvatarBytes {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
detected := http.DetectContentType(data)
|
||||
if !safeAccountImageType(detected) {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
return data, detected, true, nil
|
||||
}
|
||||
|
||||
func bestAccountPhotoSize(sizes []domain.PhotoSize) (domain.PhotoSize, []byte, bool) {
|
||||
var (
|
||||
best domain.PhotoSize
|
||||
bestBytes []byte
|
||||
bestScore int64 = -1
|
||||
)
|
||||
for _, size := range sizes {
|
||||
if !validAccountPhotoSizeType(size.Type) {
|
||||
continue
|
||||
}
|
||||
var inline []byte
|
||||
switch size.Kind {
|
||||
case domain.PhotoSizeKindCached:
|
||||
if len(size.Bytes) == 0 || len(size.Bytes) > maxAccountAvatarBytes {
|
||||
continue
|
||||
}
|
||||
inline = size.Bytes
|
||||
case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive:
|
||||
// Downloadable static raster size.
|
||||
default:
|
||||
continue
|
||||
}
|
||||
score := int64(size.W) * int64(size.H)
|
||||
if score <= 0 {
|
||||
score = int64(size.Size)
|
||||
}
|
||||
if score > bestScore {
|
||||
best, bestBytes, bestScore = size, inline, score
|
||||
}
|
||||
}
|
||||
return best, bestBytes, bestScore >= 0
|
||||
}
|
||||
|
||||
func validAccountPhotoSizeType(value string) bool {
|
||||
if value == "" || len(value) > 8 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeAccountImageType(value string) bool {
|
||||
switch value {
|
||||
case "image/jpeg", "image/png", "image/gif", "image/webp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) OfficialStarGiftAnimation(ctx context.Context, sourceGiftID string) ([]byte, bool, error) {
|
||||
if s == nil || s.officialGifts == nil || s.gifts == nil {
|
||||
return nil, false, officialgifts.ErrUnavailable
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type Config struct {
|
|||
}
|
||||
|
||||
type Service interface {
|
||||
AccountAvatar(ctx context.Context, userID int64) ([]byte, string, bool, error)
|
||||
SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error)
|
||||
GrantPremium(ctx context.Context, req admin.GrantPremiumRequest) (admin.CommandResult, error)
|
||||
GrantStars(ctx context.Context, req admin.GrantStarsRequest) (admin.CommandResult, error)
|
||||
|
|
@ -93,6 +94,7 @@ func (s *Server) routes() http.Handler {
|
|||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
mux.HandleFunc("POST /v1/accounts/set-frozen", s.authenticated(s.handleSetAccountFrozen))
|
||||
mux.HandleFunc("GET /v1/accounts/{id}/avatar", s.authenticated(s.handleAccountAvatar))
|
||||
mux.HandleFunc("POST /v1/accounts/grant-premium", s.authenticated(s.handleGrantPremium))
|
||||
mux.HandleFunc("POST /v1/accounts/grant-stars", s.authenticated(s.handleGrantStars))
|
||||
mux.HandleFunc("POST /v1/accounts/set-verified", s.authenticated(s.handleSetVerified))
|
||||
|
|
@ -125,6 +127,28 @@ func (s *Server) authenticated(next http.HandlerFunc) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || userID <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data, mimeType, found, err := s.svc.AccountAvatar(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetAccountFrozen(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetAccountFrozenRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
|
|
|
|||
|
|
@ -271,6 +271,10 @@ func (fakeService) ImportAllOfficialStarGifts(_ context.Context, req admin.Impor
|
|||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) AccountAvatar(context.Context, int64) ([]byte, string, bool, error) {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
|
||||
func (fakeService) OfficialStarGifts(context.Context) ([]officialgifts.GiftSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue