This commit is contained in:
ddrwode
2026-02-02 13:48:11 +08:00
parent 407471c1ff
commit fc0679b199
20 changed files with 6082 additions and 167 deletions

View File

@@ -432,7 +432,9 @@ def get_product_with_prices(request, product_id: int):
@paginate(PageNumberPagination, page_size=20)
@cache_page(settings.CACHE_TTL_SECONDS)
def search_products(request, q: str, filters: ProductSearchFilter = Query(...)):
"""Search approved products by name or description."""
"""Search approved products by name, description, or by user_id (submitter)."""
from apps.users.models import User
prices_prefetch = Prefetch(
"prices",
queryset=ProductPrice.objects.select_related("website"),
@@ -444,6 +446,12 @@ def search_products(request, q: str, filters: ProductSearchFilter = Query(...)):
)
if filters.category_id:
products = products.filter(category_id=filters.category_id)
if filters.user_id:
try:
submitter = User.objects.get(user_id=filters.user_id)
products = products.filter(submitted_by=submitter)
except User.DoesNotExist:
products = products.none()
needs_price_stats = (
filters.min_price is not None

View File

@@ -0,0 +1,14 @@
# Generated by Django 4.2.27 on 2026-01-30 08:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('products', '0002_product_images'),
('products', '0004_product_reject_reason_product_reviewed_at_and_more'),
]
operations = [
]

View File

@@ -157,6 +157,7 @@ class ProductFilter(FilterSchema):
class ProductSearchFilter(FilterSchema):
"""Product search filter schema."""
category_id: Optional[int] = None
user_id: Optional[str] = None # 按用户ID筛选该用户提交的商品
min_price: Optional[Decimal] = None
max_price: Optional[Decimal] = None
sort_by: Optional[str] = None

View File

@@ -5,9 +5,9 @@ from .models import User
@admin.register(User)
class UserAdmin(BaseUserAdmin):
list_display = ['id', 'open_id', 'name', 'email', 'role', 'is_active', 'created_at']
list_display = ['id', 'user_id', 'open_id', 'name', 'email', 'role', 'is_active', 'created_at']
list_filter = ['role', 'is_active', 'is_staff']
search_fields = ['open_id', 'name', 'email']
search_fields = ['user_id', 'open_id', 'name', 'email']
ordering = ['-created_at']
fieldsets = (

View File

@@ -20,6 +20,7 @@ def serialize_user_brief(user: User) -> UserBrief:
return UserBrief(
id=user.id,
open_id=user.open_id,
user_id=user.user_id,
name=user.name,
email=user.email,
avatar=user.avatar,
@@ -58,6 +59,15 @@ def list_friends(request):
return friends
@router.get("/requests/incoming/count", auth=JWTAuth())
def incoming_count(request):
"""Lightweight: only return count of pending incoming requests (for badge)."""
count = FriendRequest.objects.filter(
receiver=request.auth, status=FriendRequest.Status.PENDING
).count()
return {"count": count}
@router.get("/requests/incoming", response=List[FriendRequestOut], auth=JWTAuth())
def list_incoming_requests(request):
"""List incoming friend requests."""
@@ -171,12 +181,18 @@ def cancel_request(request, request_id: int):
@router.get("/search", response=List[UserBrief], auth=JWTAuth())
def search_users(request, q: Optional[str] = None, limit: int = 10):
"""Search users by open_id or name."""
"""Search users by user_id (exact), open_id or name."""
if not q:
return []
q_clean = (q or "").strip()
queryset = (
User.objects.filter(Q(open_id__icontains=q) | Q(name__icontains=q))
User.objects.filter(
Q(user_id__iexact=q_clean)
| Q(user_id__icontains=q_clean)
| Q(open_id__icontains=q_clean)
| Q(name__icontains=q_clean)
)
.exclude(id=request.auth.id)
.order_by("id")[: max(1, min(limit, 50))]
)

View File

@@ -0,0 +1,48 @@
# Generated manually for user_id (shareable ID for friend/product search)
import secrets
import string
from django.db import migrations, models
def generate_user_id_for_user(User):
alphabet = string.ascii_lowercase + string.digits
for _ in range(10):
candidate = "U" + "".join(secrets.choice(alphabet) for _ in range(8))
if not User.objects.filter(user_id=candidate).exists():
return candidate
return "U" + secrets.token_hex(4)
def populate_user_id(apps, schema_editor):
User = apps.get_model("users", "User")
for user in User.objects.filter(user_id__isnull=True).iterator():
user.user_id = generate_user_id_for_user(User)
user.save(update_fields=["user_id"])
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("users", "0004_rename_friendreq_receiver_status_idx_friend_requ_receive_383c2c_idx_and_more"),
]
operations = [
migrations.AddField(
model_name="user",
name="user_id",
field=models.CharField(
blank=True,
help_text="可分享的用户ID用于好友搜索、商品搜索等",
max_length=16,
null=True,
unique=True,
verbose_name="用户ID",
),
),
migrations.RunPython(populate_user_id, noop),
]

View File

@@ -1,6 +1,8 @@
"""
User models for authentication and profile management.
"""
import secrets
import string
from django.db import models
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
@@ -37,6 +39,14 @@ class User(AbstractBaseUser, PermissionsMixin):
id = models.AutoField(primary_key=True)
open_id = models.CharField('OpenID', max_length=64, unique=True)
user_id = models.CharField(
'用户ID',
max_length=16,
unique=True,
blank=True,
null=True,
help_text='可分享的用户ID用于好友搜索、商品搜索等',
)
name = models.CharField('用户名', max_length=255, blank=True, null=True)
email = models.EmailField('邮箱', max_length=320, blank=True, null=True)
avatar = models.TextField('头像', blank=True, null=True)
@@ -70,11 +80,26 @@ class User(AbstractBaseUser, PermissionsMixin):
indexes = [
models.Index(fields=["role", "is_active"]),
models.Index(fields=["created_at"]),
models.Index(fields=["user_id"]),
]
def __str__(self):
return self.name or self.open_id
def _generate_user_id(self):
"""Generate a unique shareable user_id (e.g. U8x3k2m1)."""
alphabet = string.ascii_lowercase + string.digits
for _ in range(10):
candidate = "U" + "".join(secrets.choice(alphabet) for _ in range(8))
if not User.objects.filter(user_id=candidate).exists():
return candidate
return "U" + secrets.token_hex(4)
def save(self, *args, **kwargs):
if not self.user_id:
self.user_id = self._generate_user_id()
super().save(*args, **kwargs)
@property
def is_admin(self):
return self.role == self.Role.ADMIN

View File

@@ -10,6 +10,7 @@ class UserOut(Schema):
"""Public user output schema."""
id: int
open_id: str
user_id: Optional[str] = None # 可分享的用户ID用于好友/商品搜索
name: Optional[str] = None
email: Optional[str] = None
avatar: Optional[str] = None
@@ -28,6 +29,7 @@ class UserBrief(Schema):
"""Minimal user info for social features."""
id: int
open_id: str
user_id: Optional[str] = None # 可分享的用户ID
name: Optional[str] = None
email: Optional[str] = None
avatar: Optional[str] = None