first commit

This commit is contained in:
flowerstonezl
2026-05-11 19:34:35 +08:00
commit c90b10cd5a
89 changed files with 6142 additions and 0 deletions

7
backend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.venv
__pycache__
*.py[cod]
db.sqlite3
media
staticfiles
.git

25
backend/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM python:3.12-slim-bookworm
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV DJANGO_DEBUG=false
ENV SQLITE_PATH=/data/db.sqlite3
ENV MEDIA_ROOT=/data/media
ENV DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backend
ENV CORS_ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080
RUN apt-get update \
&& apt-get install -y --no-install-recommends libjpeg62-turbo zlib1g \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chmod +x /app/docker-entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["/app/docker-entrypoint.sh"]

View File

@@ -0,0 +1,6 @@
#!/bin/sh
set -e
mkdir -p "$(dirname "${SQLITE_PATH:-/data/db.sqlite3}")" "${MEDIA_ROOT:-/data/media}"
python manage.py migrate --noinput
python manage.py collectstatic --noinput
exec gunicorn ourstory.wsgi:application --bind 0.0.0.0:8000 --workers 2

View File

196
backend/journal/admin.py Normal file
View File

@@ -0,0 +1,196 @@
from django.contrib import admin
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from .models import Config, Guestbook, Memory, MemoryPhoto
class JournalStaffAccessMixin:
"""
手账站点多为小项目:凡 is_staff 即可在后台增删改查(无需在「用户」里逐项勾选 journal.* 权限)。
生产环境若需细分权限,可移除此 Mixin 并改回 Django 默认的 has_perm 校验。
"""
def has_module_permission(self, request):
u = request.user
return bool(getattr(u, "is_active", False) and getattr(u, "is_staff", False))
def has_view_permission(self, request, obj=None):
u = request.user
return bool(getattr(u, "is_active", False) and getattr(u, "is_staff", False))
def has_change_permission(self, request, obj=None):
u = request.user
return bool(getattr(u, "is_active", False) and getattr(u, "is_staff", False))
def has_add_permission(self, request):
u = request.user
return bool(getattr(u, "is_active", False) and getattr(u, "is_staff", False))
def has_delete_permission(self, request, obj=None):
u = request.user
return bool(getattr(u, "is_active", False) and getattr(u, "is_staff", False))
class MemoryPhotoInline(admin.TabularInline):
model = MemoryPhoto
extra = 1
fields = ("image", "sort_order")
@admin.register(Memory)
class MemoryAdmin(JournalStaffAccessMixin, admin.ModelAdmin):
inlines = (MemoryPhotoInline,)
list_display = (
"event_title",
"story_order",
"date",
"unique_feature_type",
"nepal_art_style",
"is_finale",
"thumb_preview",
)
list_display_links = ("event_title",)
list_filter = ("unique_feature_type", "nepal_art_style", "is_finale", "date")
list_editable = ("story_order",)
search_fields = ("event_title", "main_text")
readonly_fields = ("image_preview",)
ordering = ("story_order", "id")
fieldsets = (
(
"叙事",
{
"fields": (
"story_order",
"date",
"event_title",
"is_finale",
)
},
),
(
"版式",
{
"fields": (
"unique_feature_type",
"nepal_art_style",
"image",
"image_preview",
"main_text",
)
},
),
)
@admin.display(description="缩略图")
def thumb_preview(self, obj: Memory) -> str:
url = None
if obj.image:
url = obj.image.url
else:
first = obj.photos.order_by("sort_order", "id").first()
if first:
url = first.image.url
if not url:
return ""
return format_html(
'<img src="{}" style="max-height:48px;border-radius:4px;" />',
url,
)
@admin.display(description="配图预览")
def image_preview(self, obj: Memory) -> str:
chunks: list[str] = []
if obj.image:
chunks.append(
format_html(
'<div class="mb-2"><span style="font-size:11px;color:#666">主图</span><br>'
'<img src="{}" style="max-width:280px;border-radius:8px;" /></div>',
obj.image.url,
)
)
for p in obj.photos.order_by("sort_order", "id"):
chunks.append(
format_html(
'<img src="{}" style="max-width:200px;border-radius:6px;margin:4px;" />',
p.image.url,
)
)
if not chunks:
return ""
return mark_safe("".join(str(c) for c in chunks))
@admin.register(Guestbook)
class GuestbookAdmin(JournalStaffAccessMixin, admin.ModelAdmin):
list_display = ("nickname", "short_message", "created_at")
search_fields = ("nickname", "message")
readonly_fields = ("created_at",)
@admin.display(description="留言")
def short_message(self, obj: Guestbook) -> str:
return obj.message[:80] + ("" if len(obj.message) > 80 else "")
@admin.register(Config)
class ConfigAdmin(admin.ModelAdmin):
"""
站点配置单独写权限:不继承 JournalStaffAccessMixin避免 MRO/多重继承下
has_change_permission 未按预期生效,导致 change 页全部为只读且 submit_row 不显示「保存」。
"""
save_on_top = True
list_display = (
"site_title",
"together_since",
"bgm_enabled",
"unlock_at",
)
list_display_links = ("site_title",)
fieldsets = (
(
"站点",
{"fields": ("site_title",)},
),
(
"时间与音乐",
{
"fields": (
"together_since",
"bgm_enabled",
"unlock_at",
),
"description": (
"「在一起」时间用于首页计数。顶部网易云播放器在前端 .env 的 "
"VITE_NETEASE_PLAYER_SRC 配置。unlock_at 已不使用,可留空。"
),
},
),
)
def _staff_ok(self, request) -> bool:
u = request.user
return bool(
getattr(u, "is_active", False)
and getattr(u, "is_authenticated", False)
and (getattr(u, "is_staff", False) or getattr(u, "is_superuser", False))
)
def has_module_permission(self, request):
return self._staff_ok(request)
def has_view_permission(self, request, obj=None):
return self._staff_ok(request)
def has_change_permission(self, request, obj=None):
return self._staff_ok(request)
def has_add_permission(self, request):
if not self._staff_ok(request):
return False
return not Config.objects.exists()
def has_delete_permission(self, request, obj=None):
return False

6
backend/journal/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class JournalConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'journal'

View File

View File

@@ -0,0 +1,85 @@
from datetime import date
from django.core.management.base import BaseCommand
from journal.models import Memory
class Command(BaseCommand):
help = "Seed 2026 春季时间线:遇见→争执→和好→租房→宜家,终章心窗。"
def handle(self, *args, **options):
Memory.objects.all().delete()
Memory.objects.create(
story_order=10,
date=date(2026, 4, 20),
event_title="第一次遇见你",
main_text=(
"**2026 年 4 月 20 日**,故事从这里开始。\n\n"
"相识、相见、相遇——我把这一页留给那天的风与心跳。"
),
unique_feature_type=Memory.FeatureType.STANDARD_NEPAL,
nepal_art_style=Memory.NepalArtStyle.DAWN_MEET,
is_finale=False,
)
Memory.objects.create(
story_order=20,
date=date(2026, 5, 3),
event_title="那天我们吵架了",
main_text="旁人皆三千江水,唯你是心上涟漪。",
unique_feature_type=Memory.FeatureType.FISSURE_PAGE,
nepal_art_style=Memory.NepalArtStyle.BLUSH_ARGUE,
is_finale=False,
)
Memory.objects.create(
story_order=30,
date=date(2026, 5, 4),
event_title="和好了,在一起吧",
main_text=(
"**5 月 4 日**:别扭说开以后,空气又变甜了。\n\n"
"谢谢你愿意和我把话说完。"
),
unique_feature_type=Memory.FeatureType.STANDARD_NEPAL,
nepal_art_style=Memory.NepalArtStyle.MINT_RECONCILE,
is_finale=False,
)
Memory.objects.create(
story_order=40,
date=date(2026, 5, 8),
event_title="一起租了房",
main_text=(
"钥匙圈上的小叮当,和阳台晒进来的光。\n\n"
"> 这是我们共同的小宇宙开始成形的日子。"
),
unique_feature_type=Memory.FeatureType.STANDARD_NEPAL,
nepal_art_style=Memory.NepalArtStyle.SAFFRON_HOME,
is_finale=False,
)
Memory.objects.create(
story_order=50,
date=date(2026, 5, 10),
event_title="逛宜家的小日子",
main_text=(
"推车里的台灯、地毯和一杯热狗,"
"都是以后会说「还记得吗」的碎片。"
),
unique_feature_type=Memory.FeatureType.STANDARD_NEPAL,
nepal_art_style=Memory.NepalArtStyle.INDIGO_IKEA,
is_finale=False,
)
Memory.objects.create(
story_order=90,
date=date(2026, 5, 20),
event_title="心墙与窗 · 终章",
main_text=(
"不开心就打开心墙。\n\n"
"**我会在你身边。**\n\n"
"—— *open when you need me*"
),
unique_feature_type=Memory.FeatureType.HEART_WINDOW,
nepal_art_style=Memory.NepalArtStyle.LOTUS_PETAL,
is_finale=True,
)
self.stdout.write(self.style.SUCCESS("Seeded %s memories." % Memory.objects.count()))

View File

@@ -0,0 +1,56 @@
# Generated by Django 5.2.14 on 2026-05-11 10:13
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Config',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('unlock_at', models.DateTimeField(help_text='Story unlocks at this time (timezone-aware).')),
('bgm_enabled', models.BooleanField(default=True)),
('site_title', models.CharField(blank=True, default='Nepal Journey: Our Story', max_length=120)),
],
options={
'verbose_name_plural': 'Config',
},
),
migrations.CreateModel(
name='Guestbook',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('nickname', models.CharField(max_length=80)),
('message', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name_plural': 'Guestbook entries',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Memory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('story_order', models.PositiveIntegerField(db_index=True, default=0, help_text='叙事顺序,数字越小越靠前。')),
('date', models.DateField()),
('event_title', models.CharField(max_length=200)),
('main_text', models.TextField(blank=True, help_text='支持 Markdown。')),
('unique_feature_type', models.CharField(choices=[('heart_window', '心窗页'), ('fissure_page', '裂缝对开页'), ('standard_nepal', '标准尼泊尔页')], default='standard_nepal', max_length=32)),
('nepal_art_style', models.CharField(blank=True, choices=[('none', '默认'), ('mandala_pink', '粉曼陀罗'), ('mandala_blue', '蓝曼陀罗'), ('spiral_rose', '玫瑰螺旋'), ('spiral_cyan', '青色螺旋')], default='none', max_length=32)),
('image', models.ImageField(blank=True, null=True, upload_to='memories/')),
('is_finale', models.BooleanField(default=False, help_text='终章:在解锁时间前仅可预览占位,到点后需手动打开。')),
],
options={
'ordering': ['story_order', 'id'],
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.14 on 2026-05-11 10:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('journal', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='memory',
name='nepal_art_style',
field=models.CharField(blank=True, choices=[('none', '默认米黄'), ('mandala_pink', '粉曼陀罗'), ('mandala_blue', '蓝曼陀罗'), ('spiral_rose', '玫瑰螺旋'), ('spiral_cyan', '青色螺旋'), ('dawn_meet', '晨光·遇见'), ('blush_argue', '粉雾·争执'), ('mint_reconcile', '薄荷·和好'), ('saffron_home', '姜黄·小家'), ('indigo_ikea', '靛蓝·漫游'), ('lotus_petal', '莲花瓣'), ('lokta_gold', '金边手工纸'), ('ocean_prayer', '海洋祈愿'), ('desert_rose', '沙漠玫瑰'), ('night_star', '夜空星屑'), ('terra_torn', '赭石撕边')], default='none', help_text='标准页背景/纹理;心窗与裂缝页也可作辅色参考。', max_length=48),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.14 on 2026-05-11 10:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('journal', '0002_expand_nepal_art_style_length'),
]
operations = [
migrations.AddField(
model_name='config',
name='netease_player_url',
field=models.URLField(blank=True, help_text='网易云「外链播放器」生成的地址,须以 https://music.163.com/ 开头。留空则不显示播放器。', max_length=512),
),
migrations.AddField(
model_name='config',
name='together_since',
field=models.DateTimeField(blank=True, help_text='用于首页「我们在一起多少天多少秒」的起点(建议为正式在一起的时刻,含时区)。', null=True),
),
migrations.AlterField(
model_name='config',
name='bgm_enabled',
field=models.BooleanField(default=True, help_text='是否展示顶部音乐区(网易云 iframe'),
),
migrations.AlterField(
model_name='config',
name='unlock_at',
field=models.DateTimeField(blank=True, help_text='已弃用:前端不再按此锁定章节,可留空。', null=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.14 on 2026-05-11 10:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('journal', '0003_config_together_netease_unlock_nullable'),
]
operations = [
migrations.AlterField(
model_name='config',
name='netease_player_url',
field=models.TextField(blank=True, help_text='粘贴网易云外链播放器的整段 <iframe …>,或只粘贴 src 里的链接(支持 //music.163.com/…)。留空则不显示播放器。'),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.14 on 2026-05-11 11:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('journal', '0004_netease_player_textfield'),
]
operations = [
migrations.RemoveField(
model_name='config',
name='netease_player_url',
),
migrations.AlterField(
model_name='config',
name='bgm_enabled',
field=models.BooleanField(default=True, help_text='是否展示顶部音乐区(播放器地址在前端环境变量 VITE_NETEASE_PLAYER_SRC 配置)。'),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.14 on 2026-05-11 11:29
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('journal', '0005_remove_netease_from_config'),
]
operations = [
migrations.CreateModel(
name='MemoryPhoto',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='memory_photos/')),
('sort_order', models.PositiveSmallIntegerField(default=0, help_text='数字越小越靠前(排在主图之后)。')),
('memory', models.ForeignKey(help_text='所属故事页', on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='journal.memory')),
],
options={
'verbose_name': '附加照片',
'verbose_name_plural': '附加照片',
'ordering': ['sort_order', 'id'],
},
),
]

View File

143
backend/journal/models.py Normal file
View File

@@ -0,0 +1,143 @@
from django.db import models
class Memory(models.Model):
"""恋爱故事页,驱动前端尼泊尔本版式。"""
class FeatureType(models.TextChoices):
HEART_WINDOW = "heart_window", "心窗页"
FISSURE_PAGE = "fissure_page", "裂缝对开页"
STANDARD_NEPAL = "standard_nepal", "标准尼泊尔页"
class NepalArtStyle(models.TextChoices):
"""每页可选不同尼泊尔视觉皮肤(备用可继续加枚举)。"""
NONE = "none", "默认米黄"
MANDALA_PINK = "mandala_pink", "粉曼陀罗"
MANDALA_BLUE = "mandala_blue", "蓝曼陀罗"
SPIRAL_ROSE = "spiral_rose", "玫瑰螺旋"
SPIRAL_CYAN = "spiral_cyan", "青色螺旋"
DAWN_MEET = "dawn_meet", "晨光·遇见"
BLUSH_ARGUE = "blush_argue", "粉雾·争执"
MINT_RECONCILE = "mint_reconcile", "薄荷·和好"
SAFFRON_HOME = "saffron_home", "姜黄·小家"
INDIGO_IKEA = "indigo_ikea", "靛蓝·漫游"
LOTUS_PETAL = "lotus_petal", "莲花瓣"
LOKTA_GOLD = "lokta_gold", "金边手工纸"
OCEAN_PRAYER = "ocean_prayer", "海洋祈愿"
DESERT_ROSE = "desert_rose", "沙漠玫瑰"
NIGHT_STAR = "night_star", "夜空星屑"
TERRA_TORN = "terra_torn", "赭石撕边"
story_order = models.PositiveIntegerField(
default=0,
db_index=True,
help_text="叙事顺序,数字越小越靠前。",
)
date = models.DateField()
event_title = models.CharField(max_length=200)
main_text = models.TextField(
blank=True,
help_text="支持 Markdown。",
)
unique_feature_type = models.CharField(
max_length=32,
choices=FeatureType.choices,
default=FeatureType.STANDARD_NEPAL,
)
nepal_art_style = models.CharField(
max_length=48,
choices=NepalArtStyle.choices,
default=NepalArtStyle.NONE,
blank=True,
help_text="标准页背景/纹理;心窗与裂缝页也可作辅色参考。",
)
image = models.ImageField(upload_to="memories/", blank=True, null=True)
is_finale = models.BooleanField(
default=False,
help_text="终章:在解锁时间前仅可预览占位,到点后需手动打开。",
)
class Meta:
ordering = ["story_order", "id"]
def __str__(self) -> str:
return f"{self.story_order}. {self.event_title}"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.is_finale:
Memory.objects.exclude(pk=self.pk).update(is_finale=False)
class MemoryPhoto(models.Model):
"""同一回忆页可附加多张照片;与主图 `Memory.image` 一并展示,主图在前。"""
memory = models.ForeignKey(
Memory,
on_delete=models.CASCADE,
related_name="photos",
help_text="所属故事页",
)
image = models.ImageField(upload_to="memory_photos/")
sort_order = models.PositiveSmallIntegerField(
default=0,
help_text="数字越小越靠前(排在主图之后)。",
)
class Meta:
ordering = ["sort_order", "id"]
verbose_name = "附加照片"
verbose_name_plural = "附加照片"
def __str__(self) -> str:
return f"{self.memory_id}·{self.sort_order}"
class Guestbook(models.Model):
nickname = models.CharField(max_length=80)
message = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
verbose_name_plural = "Guestbook entries"
def __str__(self) -> str:
return f"{self.nickname}: {self.message[:40]}"
class Config(models.Model):
"""Singleton-style global配置Admin 中 id=1"""
unlock_at = models.DateTimeField(
blank=True,
null=True,
help_text="已弃用:前端不再按此锁定章节,可留空。",
)
together_since = models.DateTimeField(
blank=True,
null=True,
help_text="用于首页「我们在一起多少天多少秒」的起点(建议为正式在一起的时刻,含时区)。",
)
bgm_enabled = models.BooleanField(
default=True,
help_text="是否展示顶部音乐区(播放器地址在前端环境变量 VITE_NETEASE_PLAYER_SRC 配置)。",
)
site_title = models.CharField(
max_length=120,
default="Nepal Journey: Our Story",
blank=True,
)
class Meta:
verbose_name_plural = "Config"
def __str__(self) -> str:
return "Site configuration"
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
pass

View File

@@ -0,0 +1,51 @@
from rest_framework import serializers
from .models import Guestbook, Memory
def _absolute_uri(request, path: str) -> str:
if request is not None:
return request.build_absolute_uri(path)
return path
class MemorySerializer(serializers.ModelSerializer):
"""`image` 为首张 URL兼容旧前端`images` 为主图 + 附加图完整列表。"""
image = serializers.SerializerMethodField()
images = serializers.SerializerMethodField()
class Meta:
model = Memory
fields = (
"id",
"story_order",
"date",
"event_title",
"main_text",
"unique_feature_type",
"nepal_art_style",
"image",
"images",
"is_finale",
)
def get_images(self, obj: Memory) -> list[str]:
request = self.context.get("request")
out: list[str] = []
if obj.image:
out.append(_absolute_uri(request, obj.image.url))
for p in obj.photos.all():
out.append(_absolute_uri(request, p.image.url))
return out
def get_image(self, obj: Memory) -> str | None:
imgs = self.get_images(obj)
return imgs[0] if imgs else None
class GuestbookCreateSerializer(serializers.ModelSerializer):
class Meta:
model = Guestbook
fields = ("id", "nickname", "message", "created_at")
read_only_fields = ("id", "created_at")

3
backend/journal/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

4
backend/journal/url Normal file
View File

@@ -0,0 +1,4 @@
手账站http://localhost:8080
Adminhttp://localhost:8000/admin/
http://localhost:5173/

9
backend/journal/urls.py Normal file
View File

@@ -0,0 +1,9 @@
from django.urls import path
from .views import GuestbookCreateView, MemoryListView, StatusView
urlpatterns = [
path("memories/", MemoryListView.as_view(), name="memory-list"),
path("messages/", GuestbookCreateView.as_view(), name="guestbook-create"),
path("status/", StatusView.as_view(), name="status"),
]

69
backend/journal/views.py Normal file
View File

@@ -0,0 +1,69 @@
from datetime import datetime
from zoneinfo import ZoneInfo
from django.utils import timezone
from rest_framework.generics import CreateAPIView, ListAPIView
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Config, Guestbook, Memory
from .serializers import GuestbookCreateSerializer, MemorySerializer
class MemoryListView(ListAPIView):
serializer_class = MemorySerializer
def get_queryset(self):
return (
Memory.objects.all()
.order_by("story_order", "id")
.prefetch_related("photos")
)
class GuestbookCreateView(CreateAPIView):
queryset = Guestbook.objects.all()
serializer_class = GuestbookCreateSerializer
class StatusView(APIView):
def get(self, request, *args, **kwargs):
shanghai = ZoneInfo("Asia/Shanghai")
now = timezone.now()
default_together = datetime(2026, 5, 4, 0, 0, 0, tzinfo=shanghai)
config = Config.objects.filter(pk=1).first()
if config is None:
bgm_enabled = True
site_title = "Nepal Journey: Our Story"
together_since = default_together
unlock_at = None
else:
bgm_enabled = config.bgm_enabled
site_title = config.site_title or "Nepal Journey: Our Story"
ts = config.together_since
if ts is None:
together_since = default_together
else:
together_since = (
timezone.make_aware(ts, shanghai) if timezone.is_naive(ts) else ts
)
ua = config.unlock_at
if ua is None:
unlock_at = None
else:
unlock_at = (
timezone.make_aware(ua, shanghai) if timezone.is_naive(ua) else ua
)
return Response(
{
"server_time": now,
"unlocked": True,
"unlock_at": unlock_at,
"together_since": together_since,
"bgm_enabled": bgm_enabled,
"site_title": site_title,
}
)

22
backend/manage.py Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ourstory.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

View File

16
backend/ourstory/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for ourstory project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ourstory.settings')
application = get_asgi_application()

View File

@@ -0,0 +1,139 @@
"""
Django settings for ourstory project.
"""
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get(
"DJANGO_SECRET_KEY",
"django-insecure-dev-only-change-in-production",
)
DEBUG = os.environ.get("DJANGO_DEBUG", "true").lower() in ("1", "true", "yes")
ALLOWED_HOSTS = [
h.strip()
for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
if h.strip()
]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"corsheaders",
"journal",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "ourstory.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "ourstory.wsgi.application"
_sqlite_path = os.environ.get("SQLITE_PATH")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": _sqlite_path if _sqlite_path else BASE_DIR / "db.sqlite3",
}
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
USE_TZ = True
MEDIA_URL = "/media/"
_media_root = os.environ.get("MEDIA_ROOT")
MEDIA_ROOT = Path(_media_root) if _media_root else BASE_DIR / "media"
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
"OPTIONS": {
"location": str(MEDIA_ROOT),
"base_url": MEDIA_URL,
},
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedStaticFilesStorage",
},
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
],
"DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser",
],
"DEFAULT_AUTHENTICATION_CLASSES": [],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.AllowAny",
],
}
_cors_origins = os.environ.get(
"CORS_ALLOWED_ORIGINS",
"http://localhost:5173,http://127.0.0.1:5173",
)
CORS_ALLOWED_ORIGINS = [o.strip() for o in _cors_origins.split(",") if o.strip()]
CORS_ALLOW_CREDENTIALS = True
CSRF_TRUSTED_ORIGINS = [
o.strip() for o in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") if o.strip()
]
# 未配置时至少信任本机 Admin避免部分环境下保存表单被 CSRF 拦截
if not CSRF_TRUSTED_ORIGINS:
CSRF_TRUSTED_ORIGINS = [
"http://127.0.0.1:8000",
"http://localhost:8000",
]

14
backend/ourstory/urls.py Normal file
View File

@@ -0,0 +1,14 @@
from django.conf import settings
from django.contrib import admin
from django.urls import include, path, re_path
from django.views.static import serve
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("journal.urls")),
re_path(
r"^media/(?P<path>.*)$",
serve,
{"document_root": settings.MEDIA_ROOT},
),
]

16
backend/ourstory/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for ourstory project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ourstory.settings')
application = get_wsgi_application()

6
backend/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
Django>=5.0,<6
djangorestframework>=3.15
django-cors-headers>=4.6
Pillow>=11.0
gunicorn>=23.0
whitenoise>=6.8